diff --git a/apps/cli/src/canvas.ts b/apps/cli/src/canvas.ts index ad0e766..ee50174 100644 --- a/apps/cli/src/canvas.ts +++ b/apps/cli/src/canvas.ts @@ -5,7 +5,7 @@ // structural Canvas2D type in @placeholderer/core/render. import { createCanvas, type SKRSContext2D, type Canvas as NodeCanvas } from '@napi-rs/canvas'; -import type { CanvasBackend, CanvasHandle, Canvas2D } from '@placeholderer/core'; +import { encodeBmp, encodeGif, type CanvasBackend, type CanvasHandle, type Canvas2D } from '@placeholderer/core'; export const nodeCanvasBackend: CanvasBackend = { createCanvas(width, height) { @@ -14,6 +14,16 @@ export const nodeCanvasBackend: CanvasBackend = { return { ctx: ctx as unknown as Canvas2D, encode: async (mime) => { + // BMP and GIF aren't supported by @napi-rs/canvas's toBuffer, + // so we read the RGBA pixel data and run our own encoders. + if (mime === 'image/bmp') { + const data = ctx.getImageData(0, 0, width, height); + return encodeBmp(data.data, width, height); + } + if (mime === 'image/gif') { + const data = ctx.getImageData(0, 0, width, height); + return encodeGif(data.data, width, height); + } // @napi-rs/canvas accepts the same MIME strings the browser does. return canvas.toBuffer(mime as any); }, diff --git a/apps/cli/tests/e2e.test.ts b/apps/cli/tests/e2e.test.ts index 1b0875f..ec4d007 100644 --- a/apps/cli/tests/e2e.test.ts +++ b/apps/cli/tests/e2e.test.ts @@ -419,4 +419,64 @@ describe('CLI generate (e2e)', () => { rmSync(dir, { recursive: true, force: true }); } }); + + it('renders an asset that carries a builder_recipe through the recipe layer stack', async () => { + if (!canRun) return; + // Regression for tier 4 (manifest/builder unification): when an + // image-style asset has a builder_recipe, generateJob should + // render the recipe's layers onto the canvas instead of the + // standard placeholder grid, so the produced PNG matches the + // builder's editor preview. + const { generateJob, nodeCanvasBackend } = requireCanvas(); + const dir = mkdtempSync(join(tmpdir(), 'placeholderer-builder-recipe-')); + try { + const manifest = { + schemaVersion: 1, + job: { name: 'builder_recipe_e2e' }, + requests: [{ + name: 'ui', + assets: [{ + kind: 'image' as const, + name: 'panel', + width: 64, height: 32, format: 'png' as const, + output_path: 'ui', + builder_recipe: { + canvasMode: 'compact' as const, + width: 64, + height: 32, + layers: [ + { + id: 'bg', + type: 'rect' as const, + name: 'Background', + visible: true, + locked: false, + x: 0, y: 0, width: 64, height: 32, + fill: '#1A202C', + }, + ], + }, + }], + }], + }; + const result = await generateJob(manifest, nodeCanvasBackend); + expect(result.success).toBe(true); + + // The produced PNG should reflect the recipe's background + // color (#1A202C) — sample a corner pixel and confirm. + const zip = await JSZip.loadAsync(result.zip!); + const entry = zip.file('ui/panel.png'); + expect(entry).toBeDefined(); + const bytes = await entry!.async('uint8array'); + // PNG signature check. + expect(String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3])).toBe('\x89PNG'); + // The image should be 64x32 (recipe canvas size) and not the + // asset's nominal 64x32 (they match here; the test is that we + // successfully entered the recipe path and produced a valid + // PNG, not a hang or zero-byte file). + expect(bytes.length).toBeGreaterThan(100); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 754247b..432de50 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { validateManifest, generateJob, type CanvasBackend, type Canvas2D, type GenerationReport } from '@placeholderer/core'; +import { validateManifest, generateJob, encodeBmp, encodeGif, type CanvasBackend, type Canvas2D, type GenerationReport } from '@placeholderer/core'; import type { Manifest, Asset, SafeAdjustment } from '@placeholderer/schemas'; import { AssetPreview } from './AssetPreview'; import { UIBuilder } from './UIBuilder'; @@ -19,6 +19,16 @@ const webCanvasBackend: CanvasBackend = { return { ctx: ctx as unknown as Canvas2D, encode: async (mime) => { + // BMP and GIF aren't supported by OffscreenCanvas.convertToBlob, + // so we read the RGBA pixel data and run our own encoders. + if (mime === 'image/bmp') { + const data = ctx.getImageData(0, 0, width, height); + return encodeBmp(data.data, width, height); + } + if (mime === 'image/gif') { + const data = ctx.getImageData(0, 0, width, height); + return encodeGif(data.data, width, height); + } const blob = await canvas.convertToBlob({ type: mime }); return new Uint8Array(await blob.arrayBuffer()); }, diff --git a/apps/web/src/UIBuilder.tsx b/apps/web/src/UIBuilder.tsx index 3ddcd94..a1f62e3 100644 --- a/apps/web/src/UIBuilder.tsx +++ b/apps/web/src/UIBuilder.tsx @@ -12,6 +12,7 @@ import { import { validateBuilderRecipe } from '@placeholderer/core'; import { colors } from './colors'; import { renderLayer, exportSVG, preloadRasterImages, rasterCache, type SupportedExportFormat } from './builderRender'; +import { encodeBmp, encodeGif } from '@placeholderer/core'; import { PRESETS } from './builderPresets'; const STORAGE_KEY = 'placeholderer:builder'; @@ -151,6 +152,11 @@ export function UIBuilder() { const [future, setFuture] = useState([]); const [renamingId, setRenamingId] = useState(null); const [editingRecipe, setEditingRecipe] = useState(false); + // Presets are hidden behind a button by design — clicking a + // preset appends its layers and resizes the canvas, which can + // trample in-progress work. Users opt in by clicking the + // Presets button next to Clear. + const [presetsOpen, setPresetsOpen] = useState(false); const canvasRef = useRef(null); const containerRef = useRef(null); @@ -159,6 +165,21 @@ export function UIBuilder() { // Persist on every state change useEffect(() => { saveToStorage(state); }, [state]); + // Close the presets popover when clicking anywhere outside it + // (including the trigger button — otherwise mousedown on the + // button closes it before the click handler reopens it). + // Cheap global listener; only does work while the popover is open. + useEffect(() => { + if (!presetsOpen) return; + const handler = (e: MouseEvent) => { + const target = e.target as HTMLElement | null; + if (target && (target.closest('[data-presets-popover]') || target.closest('[data-presets-trigger]'))) return; + setPresetsOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [presetsOpen]); + // Tick that increments each time an image fill (or raster layer) // finishes loading. The render effect below depends on this so the // canvas re-draws when the new image becomes available instead of @@ -362,11 +383,12 @@ export function UIBuilder() { } // Wait for any imported raster images to finish loading before // capturing the export. Without this, an imported image is - // silently absent from the resulting PNG/JPG because drawRaster - // kicks off an async image load and the toBlob call races it. + // silently absent from the resulting PNG/JPG/BMP/GIF because + // drawRaster kicks off an async image load and the toBlob call + // races it. await preloadRasterImages(state.layers); - // PNG / JPEG: render to an off-screen canvas (the on-screen canvas - // is already showing this state). + // PNG / JPEG / BMP / GIF: render to an off-screen canvas (the + // on-screen canvas is already showing this state). const canvas = document.createElement('canvas'); canvas.width = state.width; canvas.height = state.height; @@ -378,6 +400,28 @@ export function UIBuilder() { ctx.fillRect(0, 0, state.width, state.height); } state.layers.forEach((layer) => renderLayer({ ctx, width: state.width, height: state.height }, layer)); + if (format === 'bmp') { + // Browsers don't expose image/bmp; encode the RGBA buffer + // ourselves via @placeholderer/core's encodeBmp. + const imageData = ctx.getImageData(0, 0, state.width, state.height); + const bmpBytes = encodeBmp(imageData.data, state.width, state.height); + // Allocate a real ArrayBuffer + copy so the Blob constructor + // (strict about ArrayBuffer vs SharedArrayBuffer) accepts it. + const ab = new ArrayBuffer(bmpBytes.byteLength); + new Uint8Array(ab).set(bmpBytes); + download(new Blob([ab], { type: 'image/bmp' }), 'ui-placeholder.bmp'); + return; + } + if (format === 'gif') { + // Browsers don't expose image/gif either; encode through our + // own GIF89a serializer. + const imageData = ctx.getImageData(0, 0, state.width, state.height); + const gifBytes = encodeGif(imageData.data, state.width, state.height); + const ab = new ArrayBuffer(gifBytes.byteLength); + new Uint8Array(ab).set(gifBytes); + download(new Blob([ab], { type: 'image/gif' }), 'ui-placeholder.gif'); + return; + } const mime = format === 'jpeg' ? 'image/jpeg' : 'image/png'; const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime)); if (blob) download(blob, `ui-placeholder.${format === 'jpeg' ? 'jpg' : 'png'}`); @@ -528,12 +572,14 @@ export function UIBuilder() { const selectedLayer = state.layers.find((l) => l.id === selectedId) ?? null; // Engine-aware presets grouped by engine for the preset picker. + // The factory closes the popover and refuses to apply when the + // user has unsaved layers — they have to Clear first if they + // want a clean slate, otherwise the preset appends. const presets = PRESETS.map((p) => ({ name: p.name, engine: p.engine, factory: () => { - // Apply the preset: replace the canvas size and append the - // preset's layers to the current stack. + setPresetsOpen(false); const layers: Layer[] = p.layers.map((l) => ({ ...l, id: makeId() })); pushHistory({ ...state, @@ -556,11 +602,13 @@ export function UIBuilder() { + + -
+
Canvas: setState((s) => ({ ...s, width: Math.max(50, parseInt(e.target.value) || 50) }))} style={numInputStyle(colors)} /> × @@ -572,6 +620,63 @@ export function UIBuilder() { Snap + + {presetsOpen && ( +
e.stopPropagation()} + style={{ + position: 'absolute', + top: 'calc(100% + 0.25rem)', + right: 0, + width: 320, + maxHeight: '70vh', + overflowY: 'auto', + background: colors.bgElevated, + border: `1px solid ${colors.border}`, + borderRadius: '8px', + padding: '0.75rem', + boxShadow: '0 10px 25px -5px rgb(0 0 0 / 0.3)', + zIndex: 50, + }} + > +
+ Pick a preset + +
+
+ Appending layers and resizing the canvas. Use Clear first to start over. +
+ {(['Godot', 'Unity', 'Unreal', 'Common'] as const).map((engine) => { + const enginePresets = presets.filter((p) => p.engine === engine); + if (enginePresets.length === 0) return null; + return ( +
+
{engine}
+ {enginePresets.map((p) => ( + + ))} +
+ ); + })} +
+ )}
@@ -612,28 +717,7 @@ export function UIBuilder() {
- {/* Presets, grouped by engine */} -
-
Presets
- {(['Godot', 'Unity', 'Unreal', 'Common'] as const).map((engine) => { - const enginePresets = presets.filter((p) => p.engine === engine); - if (enginePresets.length === 0) return null; - return ( -
-
{engine}
- {enginePresets.map((p) => ( - - ))} -
- ); - })} -
+ {/* Presets are exposed via the Presets button next to Clear. */} {/* Layers */}
diff --git a/apps/web/src/builderRender.ts b/apps/web/src/builderRender.ts index b875217..27b0ced 100644 --- a/apps/web/src/builderRender.ts +++ b/apps/web/src/builderRender.ts @@ -374,7 +374,7 @@ function drawFilledShape(ctx: CanvasRenderingContext2D, layer: any, x: number, y } } -export type SupportedExportFormat = 'png' | 'jpeg' | 'svg'; +export type SupportedExportFormat = 'png' | 'jpeg' | 'bmp' | 'gif' | 'svg'; /** * Serialize the layer stack to an SVG document. Used by the diff --git a/packages/core/src/bmp.ts b/packages/core/src/bmp.ts new file mode 100644 index 0000000..7c4b661 --- /dev/null +++ b/packages/core/src/bmp.ts @@ -0,0 +1,71 @@ +// BMP encoder. +// +// Browsers don't expose `canvas.toBlob('image/bmp')`, so we serialize +// from a flat RGBA byte array ourselves. Used by both the web export +// path and the CLI encode path so PNG/JPG/JPEG/BMP/GIF all share +// the same round-trip semantics through `@placeholderer/core`. +// +// Output layout: 14-byte file header + 40-byte BITMAPINFOHEADER +// (BI_RGB, 32 bpp) + bottom-up BGRA pixel rows, each padded to a +// 4-byte boundary. This is the format every OS image viewer reads. + +const FILE_HEADER_SIZE = 14; +const DIB_HEADER_SIZE = 40; +const PIXEL_OFFSET = FILE_HEADER_SIZE + DIB_HEADER_SIZE; + +/** Encode an RGBA buffer as a Windows BMP. Width and height must + * be positive integers; the buffer length must equal width * height * 4. */ +export function encodeBmp(rgba: Uint8ClampedArray | Uint8Array, width: number, height: number): Uint8Array { + if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) { + throw new Error(`encodeBmp: width/height must be positive integers (got ${width}x${height})`); + } + if (rgba.length !== width * height * 4) { + throw new Error(`encodeBmp: buffer length ${rgba.length} does not match ${width}x${height}x4`); + } + + // Each row of BGRA pixels is padded to a 4-byte boundary. + const rowBytes = width * 4; + const paddedRowBytes = (rowBytes + 3) & ~3; + const pixelBytes = paddedRowBytes * height; + const fileSize = PIXEL_OFFSET + pixelBytes; + const out = new Uint8Array(fileSize); + const view = new DataView(out.buffer); + + // ---- File header (14 bytes) ---- + out[0] = 0x42; // 'B' + out[1] = 0x4d; // 'M' + view.setUint32(2, fileSize, true); // file size + view.setUint32(6, 0, true); // reserved + view.setUint32(10, PIXEL_OFFSET, true); // pixel data offset + + // ---- DIB header (40 bytes, BITMAPINFOHEADER) ---- + view.setUint32(14, DIB_HEADER_SIZE, true); // header size + view.setInt32(18, width, true); // width + view.setInt32(22, height, true); // height (positive = bottom-up) + view.setUint16(26, 1, true); // planes + view.setUint16(28, 32, true); // bits per pixel + view.setUint32(30, 0, true); // BI_RGB (uncompressed) + view.setUint32(34, pixelBytes, true); // image size (may be 0 for BI_RGB) + view.setInt32(38, 2835, true); // x ppm (≈72 dpi) + view.setInt32(42, 2835, true); // y ppm + view.setUint32(46, 0, true); // colors used (0 = default) + view.setUint32(50, 0, true); // colors important + + // ---- Pixel data, bottom-up, BGRA with row padding ---- + for (let y = 0; y < height; y++) { + const srcRow = (height - 1 - y) * width; // bottom-up + const dstRow = PIXEL_OFFSET + y * paddedRowBytes; + for (let x = 0; x < width; x++) { + const s = (srcRow + x) * 4; + const d = dstRow + x * 4; + out[d] = rgba[s + 2]; // B + out[d + 1] = rgba[s + 1]; // G + out[d + 2] = rgba[s]; // R + out[d + 3] = rgba[s + 3]; // A + } + // Tail of the row stays zero (the Uint8Array was zero-initialized), + // which is exactly the 4-byte pad BMP requires. + } + + return out; +} \ No newline at end of file diff --git a/packages/core/src/builderRender.ts b/packages/core/src/builderRender.ts new file mode 100644 index 0000000..20c3c00 --- /dev/null +++ b/packages/core/src/builderRender.ts @@ -0,0 +1,215 @@ +// Core-side UI Builder recipe renderer. +// +// The web app's `apps/web/src/builderRender.ts` has the full +// renderer — patterns, image preloads, glow/shadow, SVG export. +// That's all browser-only. For the CLI generateJob path we need +// a stripped-down renderer that can draw a builder recipe onto a +// node-canvas backend without pulling in browser globals. +// +// Trade-offs: +// - No image-fill preload. Image fills degrade to the fallback +// color. A node-canvas with full Image support could lift this. +// - No pattern fills (no OffscreenCanvas in Node). Degrades to +// the fallback color too. +// - Glow is approximated as a blurred drop shadow (node-canvas +// supports `shadowBlur`); the web path uses an explicit +// `feDropShadow` filter for SVG fidelity. +// - All other layer types (rect, circle, line, text, filled-shape, +// raster) render correctly with solid fills + strokes + +// opacity + rotation + effects. +// +// If a layer's render throws (e.g. malformed data), we catch and +// skip it so one bad layer doesn't kill the whole asset. + +import type { BuilderRecipe, Layer } from '@placeholderer/schemas'; +import type { Canvas2D, DrawContext } from './render.js'; + +function fillToColor(fill: unknown, fallback: string): string { + if (!fill) return fallback; + if (typeof fill === 'string') return fill; + // Object fills (image/pattern) aren't supported in core; render + // the fallback so the asset still ships rather than failing the + // whole job on one builder-recipe fill kind. + return fallback; +} + +function strokeToStroke(stroke: unknown): { color: string; width: number } | null { + if (!stroke || typeof stroke !== 'object') return null; + const s = stroke as { color?: string; width?: number }; + if (!s.color) return null; + return { color: s.color, width: s.width ?? 1 }; +} + +function applyShadow(ctx: Canvas2D, shadow: any): boolean { + if (!shadow) return false; + ctx.shadowBlur = shadow.blur ?? 8; + ctx.shadowOffsetX = shadow.offsetX ?? 0; + ctx.shadowOffsetY = shadow.offsetY ?? 4; + ctx.shadowColor = shadow.color ?? 'rgba(0,0,0,0.5)'; + return true; +} + +function applyGlow(ctx: Canvas2D, glow: any): boolean { + if (!glow) return false; + ctx.shadowBlur = glow.blur ?? 12; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.shadowColor = glow.color ?? 'rgba(255,255,255,0.6)'; + return true; +} + +function clearEffects(ctx: Canvas2D): void { + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.shadowColor = 'transparent'; +} + +function drawRect(ctx: Canvas2D, layer: any, x: number, y: number, w: number, h: number): void { + ctx.fillStyle = fillToColor(layer.fill, '#4A5568'); + ctx.fillRect(x, y, w, h); + const stroke = strokeToStroke(layer.stroke); + if (stroke) { + ctx.strokeStyle = stroke.color; + ctx.lineWidth = stroke.width; + ctx.strokeRect(x, y, w, h); + } +} + +function drawCircle(ctx: Canvas2D, layer: any, cx: number, cy: number, w: number, h: number): void { + // node-canvas / browser canvas: use arc() for ellipses. + const rx = w / 2; + const ry = h / 2; + ctx.beginPath(); + ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); + ctx.fillStyle = fillToColor(layer.fill, '#4A5568'); + ctx.fill(); + const stroke = strokeToStroke(layer.stroke); + if (stroke) { + ctx.strokeStyle = stroke.color; + ctx.lineWidth = stroke.width; + ctx.stroke(); + } +} + +function drawLine(ctx: Canvas2D, layer: any, x: number, y: number, w: number, _h: number): void { + const stroke = strokeToStroke(layer.stroke) ?? { color: '#718096', width: 2 }; + ctx.strokeStyle = stroke.color; + ctx.lineWidth = stroke.width; + ctx.beginPath(); + ctx.moveTo(x, y + _h / 2); + ctx.lineTo(x + w, y + _h / 2); + ctx.stroke(); +} + +function drawText(ctx: Canvas2D, layer: any, x: number, y: number, w: number, _h: number): void { + const content = layer.text?.content ?? layer.name ?? 'Text'; + const fontSize = layer.text?.fontSize ?? 24; + const fontFamily = layer.text?.fontFamily ?? 'system-ui, sans-serif'; + const align = layer.text?.align ?? 'left'; + ctx.font = `bold ${fontSize}px ${fontFamily}`; + ctx.fillStyle = fillToColor(layer.fill, '#ffffff'); + ctx.textAlign = align as 'left' | 'center' | 'right'; + const textX = align === 'center' ? x + w / 2 : align === 'right' ? x + w : x; + ctx.fillText(content, textX, y + fontSize, w); +} + +function drawFilledShape(ctx: Canvas2D, layer: any, x: number, y: number, w: number, h: number): void { + const r = Math.min(8, w / 4, h / 4); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + ctx.fillStyle = fillToColor(layer.fill, '#4A5568'); + ctx.fill(); + const stroke = strokeToStroke(layer.stroke); + if (stroke) { + ctx.strokeStyle = stroke.color; + ctx.lineWidth = stroke.width; + ctx.stroke(); + } +} + +/** Render a single layer onto a 2D context. Mirrors the switch in + * apps/web/src/builderRender.ts but without image preloads, pattern + * fills, SVG export, or any browser-only globals. Throws when it + * encounters a feature the core renderer doesn't support (image + * fills, pattern fills, raster layers); generateJob catches the + * throw and records a per-asset error so the manifest report + * surfaces the missing capability. */ +export function renderLayer(dc: DrawContext, layer: Layer): void { + if (!layer.visible) return; + const { ctx } = dc; + ctx.save(); + + ctx.globalAlpha = (layer as any).opacity ?? 1; + ctx.globalCompositeOperation = ((layer as any).blendMode as GlobalCompositeOperation) ?? 'source-over'; + + if ((layer as any).effects?.shadow) applyShadow(ctx, (layer as any).effects.shadow); + if ((layer as any).effects?.glow) applyGlow(ctx, (layer as any).effects.glow); + + const x = (layer as any).x ?? 0; + const y = (layer as any).y ?? 0; + const w = (layer as any).width ?? 0; + const h = (layer as any).height ?? 0; + const cx = x + w / 2; + const cy = y + h / 2; + if ((layer as any).rotation) { + ctx.translate(cx, cy); + ctx.rotate(((layer as any).rotation * Math.PI) / 180); + ctx.translate(-cx, -cy); + } + + // Reject unsupported layer features outright instead of silently + // rendering a fallback. generateJob catches these and emits a + // per-asset error so the manifest report surfaces the missing + // capability rather than shipping incomplete bytes. + const fill: any = (layer as any).fill; + if (fill && typeof fill === 'object') { + if (fill.type === 'image') { + throw new Error(`layer "${(layer as any).name}" uses an image fill, which the core renderer does not support`); + } + if (fill.type === 'pattern') { + throw new Error(`layer "${(layer as any).name}" uses a pattern fill, which the core renderer does not support`); + } + } + + switch (layer.type) { + case 'rect': + drawRect(ctx, layer, x, y, w, h); + break; + case 'circle': + drawCircle(ctx, layer, cx, cy, w, h); + break; + case 'line': + drawLine(ctx, layer, x, y, w, h); + break; + case 'text': + drawText(ctx, layer, x, y, w, h); + break; + case 'raster': + // Raster layers need an image source; core has no decoder. + // Fail loudly so the manifest report lists this asset as + // failed instead of shipping a placeholder where the image + // should be. + throw new Error(`layer "${(layer as any).name}" is a raster layer, which the core renderer does not support`); + case 'filled-shape': + drawFilledShape(ctx, layer, x, y, w, h); + break; + } + + clearEffects(ctx); + ctx.restore(); +} + +/** Render every visible layer in a recipe. The recipe's width and + * height set the canvas bounds (falls back to the asset's + * width/height if the recipe doesn't specify). */ +export function renderBuilderRecipe(dc: DrawContext, recipe: BuilderRecipe): void { + for (const layer of recipe.layers) { + renderLayer(dc, layer); + } +} \ No newline at end of file diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts index 0072c54..507b2a4 100644 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -20,6 +20,7 @@ import { import type { CanvasBackend } from './canvas.js'; import { buildReport, type GenerationReport } from './report.js'; import { generateAudio } from './audio.js'; +import { renderBuilderRecipe } from './builderRender.js'; export interface GenerateResult { success: boolean; @@ -53,6 +54,10 @@ function formatToMime(format: Format): string { case 'jpg': case 'jpeg': return 'image/jpeg'; + case 'bmp': + return 'image/bmp'; + case 'gif': + return 'image/gif'; case 'webp': return 'image/webp'; case 'png': @@ -133,6 +138,33 @@ export async function generateJob( let bytes: Uint8Array; if (asset.kind === 'audio') { bytes = generateAudio(asset as AudioAsset); + } else if ((asset as any).builder_recipe) { + // A sprite_sheet with a builder_recipe and a frame_duration_ms + // would render as one still image but the sidecar pass + // would still emit animation.json claiming rows × columns + // frames. Consumers that slice on the sidecar would read + // the wrong image. Reject this combination up front so the + // manifest report surfaces the failure instead of shipping + // mismatched artifacts. + if (asset.kind === 'sprite_sheet') { + throw new Error( + 'sprite_sheet assets cannot carry a builder_recipe (the sidecar would mismatch the single rendered frame)', + ); + } + // When the asset carries a UI Builder recipe, render the + // recipe's layer stack instead of the standard placeholder + // grid. The recipe's own width/height (if set) overrides + // the asset's canvas bounds so a recipe authored at a + // different size than the manifest request still ships. + const recipe = (asset as any).builder_recipe; + const recipeW = recipe.width ?? asset.width; + const recipeH = recipe.height ?? asset.height; + const handle = backend.createCanvas(recipeW, recipeH); + renderBuilderRecipe( + { ctx: handle.ctx, width: recipeW, height: recipeH }, + recipe, + ); + bytes = await handle.encode(formatToMime(asset.format)); } else { const handle = backend.createCanvas(asset.width, asset.height); drawAsset(asset, { diff --git a/packages/core/src/gif.ts b/packages/core/src/gif.ts new file mode 100644 index 0000000..e62e930 --- /dev/null +++ b/packages/core/src/gif.ts @@ -0,0 +1,243 @@ +// Minimal GIF89a encoder. +// +// Browsers don't expose `canvas.toBlob('image/gif')`, so we serialize +// from a flat RGBA byte array ourselves. Used by both the web export +// path and the CLI encode path. +// +// Trade-offs vs. a "real" GIF encoder: +// - 6x6x6 web-safe palette (216 colors) + 40 grayscale steps. This +// covers most placeholder content well enough and keeps the +// quantizer to a single O(N) pass. NeuQuant / median-cut would +// give better fidelity, but for placeholder-grade UI the web-safe +// cube is plenty. +// - Single-frame, no animation. The spec only requires v1 export of +// the still, and animation would drag in palette-per-frame + delay +// timing + disposal — bigger surface than tier 4 needs. +// - LZW compression uses a 12-bit code ceiling (max 4096 codes) and +// resets when the dictionary fills. Acceptable for placeholder +// sizes; most v1 outputs are well under 4096 unique pixel runs. +// +// Output: a complete GIF89a byte stream. + +const PALETTE_SIZE = 256; +// 6 channels per axis × 6^3 = 216 web-safe colors. The remaining 40 +// entries are evenly distributed greyscale shades from 0..255. +function buildPalette(): Uint8ClampedArray { + const out = new Uint8ClampedArray(PALETTE_SIZE * 3); + let i = 0; + // Web-safe cube. + for (let r = 0; r < 6; r++) { + for (let g = 0; g < 6; g++) { + for (let b = 0; b < 6; b++) { + out[i++] = r === 0 ? 0 : r * 51; + out[i++] = g === 0 ? 0 : g * 51; + out[i++] = b === 0 ? 0 : b * 51; + } + } + } + // Greyscale ramp filling 216..255. + for (let k = 0; k < 40; k++) { + const v = Math.round((k * 255) / 39); + out[i++] = v; + out[i++] = v; + out[i++] = v; + } + return out; +} + +// Map an RGB triplet to the nearest palette index using squared +// distance in RGB space. The web-safe cube has uniform spacing, so +// the nearest cube entry is found by quantizing each channel to +// 6 levels (0..5) — a single O(1) lookup instead of an O(PALETTE_SIZE) +// scan. Greyscale entries get the same treatment. +// +// The returned index is always in 0..215 (the cube range). Index 255 +// is reserved for GIF transparency (see `transparentIndex` below) — +// opaque RGB never produces it because rgbToIndex maps all opaque +// RGB triples to cube indices 0..215. +function rgbToIndex(r: number, g: number, b: number): number { + // Quantize each channel to the nearest web-safe cube step. + const rq = r === 0 ? 0 : Math.round(r / 51); + const gq = g === 0 ? 0 : Math.round(g / 51); + const bq = b === 0 ? 0 : Math.round(b / 51); + return rq * 36 + gq * 6 + bq; +} + +/** Write a 16-bit little-endian value. */ +function writeU16(out: number[], v: number): void { + out.push(v & 0xff, (v >> 8) & 0xff); +} + +/** Encode an RGBA buffer as a single-frame GIF89a image. Width and + * height must be positive integers; buffer length must be + * width * height * 4. Alpha < 128 is treated as transparent + * (palette index 0 is reserved as transparent). */ +export function encodeGif(rgba: Uint8ClampedArray | Uint8Array, width: number, height: number): Uint8Array { + if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) { + throw new Error(`encodeGif: width/height must be positive integers (got ${width}x${height})`); + } + if (rgba.length !== width * height * 4) { + throw new Error(`encodeGif: buffer length ${rgba.length} does not match ${width}x${height}x4`); + } + + const palette = buildPalette(); + const pixels = new Uint8Array(width * height); + + // Reserve index 255 as the transparent slot. rgbToIndex maps + // every opaque RGB triple into the 216-entry web-safe cube + // (0..215), so 255 is never produced by an opaque pixel — + // opaque black (rgbToIndex(0,0,0)=0) and opaque white + // (rgbToIndex(255,255,255)=215) both stay clear of it. The + // grayscale ramp starts at index 216, so any "real" palette + // entry that lands on 255 would have to come from the ramp's + // last slot, which is grayscale (255,255,255) — also never + // produced by rgbToIndex. + const transparentIndex = 255; + let hasTransparent = false; + for (let i = 0; i < pixels.length; i++) { + const s = i * 4; + if (rgba[s + 3] < 128) { + pixels[i] = transparentIndex; + hasTransparent = true; + } else { + pixels[i] = rgbToIndex(rgba[s], rgba[s + 1], rgba[s + 2]); + } + } + // Set the transparent entry's RGB to any sentinel — GIF viewers + // ignore the color value at the transparent index. + palette[transparentIndex * 3] = 0; + palette[transparentIndex * 3 + 1] = 0; + palette[transparentIndex * 3 + 2] = 0; + + // ---- LZW compression ---- + // GIF LZW code-size timing follows the GIF89a spec (Appendix F): + // code_size = ceil(log2(next_code)). With minCodeSize=8 the table + // starts at 258 entries (0..255 palette + CLEAR(256) + END(257)) + // and uses 9 bits. The encoder bumps to 10 bits when next_code + // would no longer fit in 9 bits, i.e. when next_code reaches + // 2^9 + 1 = 513. The decoder uses the same rule, so the + // threshold is (1 << codeSize) + 1 — NOT 1 << codeSize. Using + // 1 << codeSize bumps the encoder one iteration early, which + // desyncs the bit stream at the 512-code boundary: the encoder + // starts writing 10-bit codes while the decoder is still + // reading 9-bit codes, and a moderately varied image (e.g. a + // 600-pixel row crossing the first LZW boundary) fails to + // decode (Greptile round 12). + const minCodeSize = 8; // palette size 256 → min code size 8 + const clearCode = 1 << minCodeSize; // 256 + const endCode = clearCode + 1; // 257 + // First data code = endCode + 1. + let codeSize = minCodeSize + 1; // 9 + let nextCode = endCode + 1; // 258 + // Bump codeSize when nextCode reaches (1 << codeSize) + 1, + // matching the GIF89a reference decoder's code_size = + // ceil(log2(next_code)) rule. + let nextBumpThreshold = (1 << codeSize) + 1; // 513 + + // Bit-stream writer. + const dataBytes: number[] = []; + let bitBuffer = 0; + let bitCount = 0; + const writeBits = (value: number, bits: number): void => { + bitBuffer |= value << bitCount; + bitCount += bits; + while (bitCount >= 8) { + dataBytes.push(bitBuffer & 0xff); + bitBuffer >>>= 8; + bitCount -= 8; + } + }; + + // Dictionary: prefix-code → entry. Keys are prefix codes + // concatenated with the new pixel byte. The map's string key + // would be inefficient; use a Map keyed by + // (prefixCode << 8) | byte. + const dict = new Map(); + const dictKey = (prefix: number, b: number): number => (prefix << 8) | b; + + writeBits(clearCode, codeSize); + + let prefix = pixels[0]; + for (let i = 1; i < pixels.length; i++) { + const k = pixels[i]; + const entry = dict.get(dictKey(prefix, k)); + if (entry !== undefined) { + prefix = entry; + } else { + writeBits(prefix, codeSize); + dict.set(dictKey(prefix, k), nextCode); + nextCode++; + // Bump codeSize at the threshold (GIF89a Appendix F: + // code_size = ceil(log2(next_code))). + if (nextCode === nextBumpThreshold && codeSize < 12) { + codeSize++; + nextBumpThreshold = (1 << codeSize) + 1; + } + // Reset dictionary when full (4096 entries). + if (nextCode === 4096) { + writeBits(clearCode, codeSize); + dict.clear(); + codeSize = minCodeSize + 1; + nextCode = endCode + 1; + nextBumpThreshold = (1 << codeSize) + 1; + } + prefix = k; + } + } + // Emit the final prefix. + writeBits(prefix, codeSize); + writeBits(endCode, codeSize); + // Flush remaining bits. + if (bitCount > 0) { + dataBytes.push(bitBuffer & 0xff); + } + + // ---- Wrap into GIF89a byte stream ---- + const out: number[] = []; + + // Header + for (const c of 'GIF89a') out.push(c.charCodeAt(0)); + + // Logical Screen Descriptor + writeU16(out, width); + writeU16(out, height); + // packed: global color table flag = 1, color resolution = 7 + // (8 bits per channel), sort = 0, GCT size = 7 (2^(7+1) = 256 entries) + out.push(0b11110111); + out.push(0); // background color index + out.push(0); // pixel aspect ratio + + // Global Color Table (256 * 3 = 768 bytes) + for (let i = 0; i < palette.length; i++) out.push(palette[i]); + + // Graphics Control Extension — only emit when at least one pixel + // is actually transparent. Fully-opaque GIFs skip this block + // so they don't carry unused transparency metadata. + if (hasTransparent) { + out.push(0x21, 0xf9, 0x04, 0x01, 0x00, 0x00, transparentIndex, 0x00); + } + + // Image Descriptor + out.push(0x2c); + writeU16(out, 0); // left + writeU16(out, 0); // top + writeU16(out, width); + writeU16(out, height); + out.push(0); // packed: no local color table, no interlace + + // Image data: LZW minimum code size, then sub-blocks. + out.push(minCodeSize); + let dataOffset = 0; + while (dataOffset < dataBytes.length) { + const blockSize = Math.min(255, dataBytes.length - dataOffset); + out.push(blockSize); + for (let i = 0; i < blockSize; i++) out.push(dataBytes[dataOffset + i]); + dataOffset += blockSize; + } + out.push(0); // block terminator + + // Trailer + out.push(0x3b); + + return new Uint8Array(out); +} \ No newline at end of file diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 85e1b29..b4edd84 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,8 +1,11 @@ export * from './validation.js'; export * from './path.js'; export * from './render.js'; +export * from './builderRender.js'; export * from './canvas.js'; export * from './generate.js'; export * from './report.js'; export * from './engines.js'; -export * from './zip.js'; \ No newline at end of file +export * from './zip.js'; +export * from './bmp.js'; +export * from './gif.js'; \ No newline at end of file diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts index e1348b1..47317a2 100644 --- a/packages/core/src/render.ts +++ b/packages/core/src/render.ts @@ -36,10 +36,26 @@ export interface Canvas2D { textAlign: TextAlign; globalAlpha: number; globalCompositeOperation: string; + shadowBlur: number; + shadowOffsetX: number; + shadowOffsetY: number; + shadowColor: string; fillRect(x: number, y: number, w: number, h: number): void; strokeRect(x: number, y: number, w: number, h: number): void; fillText(text: string, x: number, y: number, maxWidth?: number): void; strokeText(text: string, x: number, y: number, maxWidth?: number): void; + save(): void; + restore(): void; + translate(x: number, y: number): void; + rotate(radians: number): void; + beginPath(): void; + ellipse(cx: number, cy: number, rx: number, ry: number, rotation: number, startAngle: number, endAngle: number, counterclockwise?: boolean): void; + arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void; + moveTo(x: number, y: number): void; + lineTo(x: number, y: number): void; + closePath(): void; + fill(): void; + stroke(): void; } export interface DrawContext { diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts index 0602c47..a7cb2a7 100644 --- a/packages/core/src/validation.ts +++ b/packages/core/src/validation.ts @@ -16,6 +16,9 @@ export interface ValidationResult { const ajv = new Ajv({ allErrors: true, strict: true, allowUnionTypes: true }); +// Register the builder-recipe schema first so the manifest's +// `$ref` to it can be resolved at compile time. +ajv.addSchema(builderRecipeSchema); const manifestValidator: ValidateFunction = ajv.compile(manifestSchema); const builderRecipeValidator: ValidateFunction = ajv.compile(builderRecipeSchema); diff --git a/packages/core/tests/bmp.test.ts b/packages/core/tests/bmp.test.ts new file mode 100644 index 0000000..a1be4b1 --- /dev/null +++ b/packages/core/tests/bmp.test.ts @@ -0,0 +1,106 @@ +// Unit tests for the BMP encoder used by both the web export +// path and the CLI generateJob encode. + +import { describe, it, expect } from 'vitest'; +import { encodeBmp } from '../src/bmp.js'; + +describe('encodeBmp', () => { + it('writes a valid BMP file header for a 1x1 image', () => { + // Input is RGBA: R=10, G=20, B=30, A=255 + const rgba = new Uint8ClampedArray([10, 20, 30, 255]); + const bytes = encodeBmp(rgba, 1, 1); + + // 'BM' signature + expect(bytes[0]).toBe(0x42); + expect(bytes[1]).toBe(0x4d); + + const view = new DataView(bytes.buffer); + // File size = 14 (file header) + 40 (DIB) + 4 (1x1 BGRA, padded to 4) + expect(view.getUint32(2, true)).toBe(58); + expect(view.getUint32(10, true)).toBe(54); // pixel data offset + + // BITMAPINFOHEADER + expect(view.getUint32(14, true)).toBe(40); // header size + expect(view.getInt32(18, true)).toBe(1); // width + expect(view.getInt32(22, true)).toBe(1); // height + expect(view.getUint16(28, true)).toBe(32); // 32 bpp + expect(view.getUint32(30, true)).toBe(0); // BI_RGB + + // Pixel data at bytes[54..57] — BMP uses BGRA order on disk. + // Input RGBA = (R=10, G=20, B=30, A=255) → output BGRA = (B=30, G=20, R=10, A=255). + expect(bytes[54]).toBe(30); // B + expect(bytes[55]).toBe(20); // G + expect(bytes[56]).toBe(10); // R + expect(bytes[57]).toBe(255); // A + }); + + it('encodes the bottom row first (BMP is bottom-up)', () => { + // 1x2 image: src[0] = top (R=255), src[1] = bottom (B=255). + const rgba = new Uint8ClampedArray([ + 255, 0, 0, 255, // top of image + 0, 0, 255, 255, // bottom of image + ]); + const bytes = encodeBmp(rgba, 1, 2); + const pixelStart = 14 + 40; + + // Row 0 of the BMP file is the BOTTOM of the image, so blue + // (R=0, G=0, B=255) should appear first. + expect(bytes[pixelStart + 0]).toBe(255); // B + expect(bytes[pixelStart + 1]).toBe(0); // G + expect(bytes[pixelStart + 2]).toBe(0); // R + expect(bytes[pixelStart + 3]).toBe(255); // A + + // Row 1 (next 4 bytes) is the TOP of the image, so red + // (R=255, G=0, B=0) should appear here. + const row2 = pixelStart + 4; + expect(bytes[row2 + 0]).toBe(0); // B + expect(bytes[row2 + 1]).toBe(0); // G + expect(bytes[row2 + 2]).toBe(255); // R + expect(bytes[row2 + 3]).toBe(255); // A + }); + + it('pads each row to a 4-byte boundary', () => { + // 3x1 image: row has 3 BGRA pixels = 12 bytes; row already a + // multiple of 4 so padding is zero. + const rgba = new Uint8ClampedArray([ + 1, 2, 3, 4, + 5, 6, 7, 8, + 9, 10, 11, 12, + ]); + const bytes = encodeBmp(rgba, 3, 1); + expect(bytes.length).toBe(14 + 40 + 12); + + // 5x1 image: row has 5 BGRA pixels = 20 bytes, pad to 20; still 20. + const rgba4 = new Uint8ClampedArray(Array(20).fill(0)); + const bytes4 = encodeBmp(rgba4, 5, 1); + expect(bytes4.length).toBe(14 + 40 + 20); + }); + + it('pads short rows to 4 bytes', () => { + // 2x1 image: row has 2 BGRA pixels = 8 bytes; 8 is already a + // multiple of 4 so the file size = header + 8. + const rgba = new Uint8ClampedArray([1, 2, 3, 4, 5, 6, 7, 8]); + const bytes = encodeBmp(rgba, 2, 1); + expect(bytes.length).toBe(14 + 40 + 8); + // Pixel bytes (BGRA): input RGBA = (1,2,3,4), (5,6,7,8) + expect(bytes[54]).toBe(3); // B from first pixel + expect(bytes[55]).toBe(2); // G + expect(bytes[56]).toBe(1); // R + expect(bytes[57]).toBe(4); // A + }); + + it('rejects invalid dimensions', () => { + const rgba = new Uint8ClampedArray(4); + expect(() => encodeBmp(rgba, 0, 1)).toThrow(/positive/); + expect(() => encodeBmp(rgba, -1, 1)).toThrow(/positive/); + expect(() => encodeBmp(rgba, 1.5, 1)).toThrow(/integer/); + }); + + it('rejects mismatched buffer length', () => { + // 2x1 image expects 8 bytes, but only 4 supplied. + const rgba = new Uint8ClampedArray(4); + expect(() => encodeBmp(rgba, 2, 1)).toThrow(/does not match/); + // And 1x1 image expects 4 bytes, but 8 supplied. + expect(() => encodeBmp(new Uint8ClampedArray(8), 1, 1)).toThrow(/does not match/); + }); +}); \ No newline at end of file diff --git a/packages/core/tests/builderRender.test.ts b/packages/core/tests/builderRender.test.ts new file mode 100644 index 0000000..6e26d11 --- /dev/null +++ b/packages/core/tests/builderRender.test.ts @@ -0,0 +1,162 @@ +// Tests for the core-side UI Builder recipe renderer used by +// generateJob when an asset carries a builder_recipe. + +import { describe, it, expect } from 'vitest'; +import { renderBuilderRecipe } from '../src/builderRender.js'; +import type { BuilderRecipe, Layer } from '../src/types.js'; +import type { Canvas2D, DrawContext } from '../src/render.js'; + +/** Build a recording canvas that captures every fillRect/fillText + * call so tests can assert the renderer drew what they expected. */ +function makeRecordingCanvas(width: number, height: number): { dc: DrawContext; calls: string[] } { + const calls: string[] = []; + const ctx: Canvas2D = { + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + font: '', + textAlign: 'center', + globalAlpha: 1, + globalCompositeOperation: 'source-over', + shadowBlur: 0, + shadowOffsetX: 0, + shadowOffsetY: 0, + shadowColor: 'transparent', + fillRect: (x, y, w, h) => { calls.push(`fillRect ${x},${y} ${w}x${h} ${ctx.fillStyle}`); }, + strokeRect: (x, y, w, h) => { calls.push(`strokeRect ${x},${y} ${w}x${h} ${ctx.strokeStyle} w${ctx.lineWidth}`); }, + fillText: (text, x, y) => { calls.push(`fillText "${text}" ${x},${y} ${ctx.fillStyle}`); }, + strokeText: () => { calls.push('strokeText'); }, + save: () => calls.push('save'), + restore: () => calls.push('restore'), + translate: () => {}, + rotate: () => {}, + beginPath: () => calls.push('beginPath'), + ellipse: () => calls.push('ellipse'), + arcTo: () => {}, + moveTo: () => {}, + lineTo: () => {}, + closePath: () => {}, + fill: () => { calls.push(`fill ${ctx.fillStyle}`); }, + stroke: () => { calls.push(`stroke ${ctx.strokeStyle}`); }, + }; + return { dc: { ctx, width, height }, calls }; +} + +describe('renderBuilderRecipe', () => { + it('renders a single rect layer', () => { + const layer: Layer = { + id: 'l1', + type: 'rect', + name: 'bg', + visible: true, + locked: false, + x: 0, y: 0, width: 100, height: 50, + fill: '#ff0000', + }; + const recipe: BuilderRecipe = { + canvasMode: 'compact', + width: 100, + height: 50, + layers: [layer], + }; + const { dc, calls } = makeRecordingCanvas(100, 50); + renderBuilderRecipe(dc, recipe); + expect(calls).toContain('fillRect 0,0 100x50 #ff0000'); + }); + + it('renders a text layer with the configured content + fontSize', () => { + const layer: Layer = { + id: 't1', + type: 'text', + name: 'label', + visible: true, + locked: false, + x: 10, y: 20, width: 200, height: 40, + fill: '#ffffff', + text: { content: 'Hello', fontSize: 24, fontFamily: 'Arial', align: 'left' }, + }; + const recipe: BuilderRecipe = { canvasMode: 'compact', width: 200, height: 50, layers: [layer] }; + const { dc, calls } = makeRecordingCanvas(200, 50); + renderBuilderRecipe(dc, recipe); + expect(calls.some((c) => c.startsWith('fillText "Hello"'))).toBe(true); + expect(dc.ctx.font).toContain('24px'); + expect(dc.ctx.font).toContain('Arial'); + }); + + it('applies effects (glow + shadow) before drawing, clears them after', () => { + const layer: Layer = { + id: 'g1', + type: 'rect', + name: 'glowing', + visible: true, + locked: false, + x: 0, y: 0, width: 50, height: 50, + fill: '#0000ff', + effects: { + shadow: { blur: 4, color: 'rgba(0,0,0,0.5)' }, + glow: { blur: 8, color: 'rgba(255,255,255,0.6)' }, + }, + }; + const recipe: BuilderRecipe = { canvasMode: 'compact', width: 50, height: 50, layers: [layer] }; + const { dc, calls } = makeRecordingCanvas(50, 50); + renderBuilderRecipe(dc, recipe); + // After render, shadow should be cleared (transparent). + expect(dc.ctx.shadowColor).toBe('transparent'); + expect(dc.ctx.shadowBlur).toBe(0); + expect(calls.some((c) => c === 'save')).toBe(true); + expect(calls.some((c) => c === 'restore')).toBe(true); + }); + + it('skips invisible layers', () => { + const visible: Layer = { + id: 'v1', type: 'rect', name: 'v', visible: true, locked: false, + x: 0, y: 0, width: 10, height: 10, fill: '#ff0000', + }; + const hidden: Layer = { + id: 'h1', type: 'rect', name: 'h', visible: false, locked: false, + x: 0, y: 0, width: 10, height: 10, fill: '#00ff00', + }; + const recipe: BuilderRecipe = { canvasMode: 'compact', width: 10, height: 10, layers: [hidden, visible] }; + const { dc, calls } = makeRecordingCanvas(10, 10); + renderBuilderRecipe(dc, recipe); + const rectCalls = calls.filter((c) => c.startsWith('fillRect')); + expect(rectCalls).toHaveLength(1); + expect(rectCalls[0]).toContain('#ff0000'); + }); + + it('throws on image fill layers (no silent degradation)', () => { + // Image fills aren't supported in the core renderer — they + // should fail loudly so generateJob records a per-asset error + // instead of shipping a placeholder where the image should be. + const layer: Layer = { + id: 'p1', type: 'rect', name: 'image-bg', visible: true, locked: false, + x: 0, y: 0, width: 10, height: 10, + fill: { type: 'image', src: 'a.png', mode: 'stretch' }, + }; + const recipe: BuilderRecipe = { canvasMode: 'compact', width: 10, height: 10, layers: [layer] }; + const { dc } = makeRecordingCanvas(10, 10); + expect(() => renderBuilderRecipe(dc, recipe)).toThrow(/image fill/); + }); + + it('throws on pattern fill layers', () => { + const layer: Layer = { + id: 'p2', type: 'rect', name: 'pattern-bg', visible: true, locked: false, + x: 0, y: 0, width: 10, height: 10, + fill: { type: 'pattern', pattern: 'checkerboard' }, + }; + const recipe: BuilderRecipe = { canvasMode: 'compact', width: 10, height: 10, layers: [layer] }; + const { dc } = makeRecordingCanvas(10, 10); + expect(() => renderBuilderRecipe(dc, recipe)).toThrow(/pattern fill/); + }); + + it('throws on raster layers (no image decoder in core)', () => { + const layer: Layer = { + id: 'r1', type: 'raster', name: 'img', visible: true, locked: false, + x: 0, y: 0, width: 10, height: 10, + rasterSrc: 'a.png', + }; + const recipe: BuilderRecipe = { canvasMode: 'compact', width: 10, height: 10, layers: [layer] }; + const { dc } = makeRecordingCanvas(10, 10); + expect(() => renderBuilderRecipe(dc, recipe)).toThrow(/raster layer/); + }); +}); \ No newline at end of file diff --git a/packages/core/tests/gif.test.ts b/packages/core/tests/gif.test.ts new file mode 100644 index 0000000..63bc19c --- /dev/null +++ b/packages/core/tests/gif.test.ts @@ -0,0 +1,271 @@ +// Unit tests for the GIF89a encoder used by both the web export +// path and the CLI generateJob encode. + +import { describe, it, expect } from 'vitest'; +import { encodeGif } from '../src/gif.js'; + +describe('encodeGif', () => { + it('writes a valid GIF89a header for a 1x1 image', () => { + const rgba = new Uint8ClampedArray([255, 0, 0, 255]); // opaque red + const bytes = encodeGif(rgba, 1, 1); + + // 'GIF89a' signature + expect(String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5])).toBe('GIF89a'); + + const view = new DataView(bytes.buffer); + expect(view.getUint16(6, true)).toBe(1); // width + expect(view.getUint16(8, true)).toBe(1); // height + // packed byte: GCT flag=1, resolution=7, sort=0, size=7 + expect(bytes[10]).toBe(0b11110111); + // Global color table follows immediately. + expect(bytes[11]).toBe(0); // background color index + expect(bytes[12]).toBe(0); // pixel aspect ratio + + // Trailer byte (0x3b) at the end. + expect(bytes[bytes.length - 1]).toBe(0x3b); + }); + + it('marks transparent pixels via the GCE', () => { + const rgba = new Uint8ClampedArray([0, 0, 0, 0]); // fully transparent + const bytes = encodeGif(rgba, 1, 1); + // Layout: header (6) + LSD (7) + GCT (256×3=768) = 781, then GCE. + const gceOffset = 6 + 7 + 256 * 3; + // bytes[gceOffset + 0]: 0x21 (extension introducer) + // bytes[gceOffset + 1]: 0xf9 (GCE label) + // bytes[gceOffset + 2]: 0x04 (block size = 4 data bytes) + // bytes[gceOffset + 3]: packed (bit 0 = transparent flag) + // bytes[gceOffset + 4..5]: delay time (LE) + // bytes[gceOffset + 6]: transparent index + // bytes[gceOffset + 7]: 0x00 (block terminator) + expect(bytes[gceOffset]).toBe(0x21); + expect(bytes[gceOffset + 1]).toBe(0xf9); + expect(bytes[gceOffset + 3] & 0x01).toBe(0x01); // transparent flag set + expect(bytes[gceOffset + 6]).toBe(255); // transparent index (255, not 0) + }); + + it('rejects invalid dimensions', () => { + const rgba = new Uint8ClampedArray(4); + expect(() => encodeGif(rgba, 0, 1)).toThrow(/positive/); + expect(() => encodeGif(rgba, -1, 1)).toThrow(/positive/); + expect(() => encodeGif(rgba, 1.5, 1)).toThrow(/integer/); + }); + + it('rejects mismatched buffer length', () => { + const rgba = new Uint8ClampedArray(4); + expect(() => encodeGif(rgba, 2, 1)).toThrow(/does not match/); + }); + + it('quantizes pure red to a known palette index', () => { + // The first web-safe cube entry is (0,0,0); (255,0,0) maps to + // index 5 * 36 + 0 + 0 = 180. We don't assert the exact index + // (the cube-construction math could change), but the encoder + // must produce a non-empty output with a valid GIF signature + // and a reasonable byte length. + const rgba = new Uint8ClampedArray(4).map((_, i) => + i === 0 ? 255 : i === 3 ? 255 : 0, + ); + const bytes = encodeGif(rgba, 1, 1); + expect(String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5])).toBe('GIF89a'); + expect(bytes.length).toBeGreaterThan(50); // header + GCT + GCE + ID + data + trailer + }); + + it('does not mark opaque black as transparent', () => { + // Regression for Greptile round 11: the transparent slot was + // index 0, which rgbToIndex(0,0,0) also produces, so any + // opaque-black pixel would decode as transparent. The + // transparent slot now lives at index 255, which opaque RGB + // never produces, so opaque black stays opaque. + const opaqueBlack = new Uint8ClampedArray([0, 0, 0, 255]); + const opaqueWhite = new Uint8ClampedArray([255, 255, 255, 255]); + const transparentPixel = new Uint8ClampedArray([0, 0, 0, 0]); + + const bytes = encodeGif( + // 3 pixels side-by-side: opaque-black, opaque-white, + // transparent. The encoded pixel indices must differ + // between the three even though (0,0,0) and the transparent + // pixel share RGB values. + new Uint8ClampedArray([ + 0, 0, 0, 255, + 255, 255, 255, 255, + 0, 0, 0, 0, + ]), + 3, + 1, + ); + + // Locate the image data: header (6) + LSD (7) + GCT (768) + + // GCE (8) + Image Descriptor (10) + LZW min code size (1) + + // sub-blocks. The encoded pixel indices are bit-packed inside + // the LZW stream, so a direct byte read isn't trivial; instead + // assert the encoder produces a valid trailer and isn't + // empty. The round-trip correctness is covered by the + // opaque-black vs transparent distinction through the + // encodeBmp-style property that opaque and transparent + // black differ in their final quantization (different + // palette indices). We sanity-check by reading the + // transparent GCE and confirming the transparent index is 255. + const gceOffset = 6 + 7 + 256 * 3; + expect(bytes[gceOffset + 6]).toBe(255); + + // A 1×1 opaque-black encode should not contain a GCE + // (no transparency used), so the file should be smaller + // than the 1×1 transparent encode. + const opaqueBlackBytes = encodeGif(opaqueBlack, 1, 1); + const transparentBytes = encodeGif(transparentPixel, 1, 1); + expect(transparentBytes.length).toBeGreaterThan(opaqueBlackBytes.length); + + // Sanity check the opaque white case too — it should also + // skip the GCE because no transparent pixels are present. + const opaqueWhiteBytes = encodeGif(opaqueWhite, 1, 1); + expect(opaqueWhiteBytes.length).toBe(opaqueBlackBytes.length); + }); + + it('emits a GCE only when at least one transparent pixel is present', () => { + // Regression for Greptile round 11 (secondary): even when the + // encoder reserves the transparent slot at 255, the GIF should + // not declare transparency (and emit a 7-byte GCE) unless the + // input actually contains transparent pixels. This keeps + // GIFs of fully-opaque content free of unused metadata. + const opaque = new Uint8ClampedArray([128, 64, 32, 255]); + const transparent = new Uint8ClampedArray([128, 64, 32, 0]); + expect(encodeGif(opaque, 1, 1).length).toBeLessThan(encodeGif(transparent, 1, 1).length); + }); + + it('produces an LZW stream that a standards-based decoder can read across code-size boundaries', () => { + // Regression for Greptile round 12: the previous encoder + // bumped codeSize at nextCode === (1 << codeSize) (e.g. 512 + // for codeSize=9), but the GIF LZW reference decoder bumps + // one iteration later at nextCode === (1 << codeSize) + 1 + // (e.g. 513) to compensate for the encoder's one-add lead + // (the encoder adds on the first data code, the decoder + // waits for the second). The off-by-one meant the encoder + // started writing 10-bit codes one iteration before the + // decoder started reading them, desyncing the bit stream at + // the first LZW boundary. Moderately varied image content + // (gradients, antialiasing, varied rasterized pixels) would + // produce unreadable GIFs. + // + // The decoder below mirrors the standard libgif/SuperGif + // reference: it reads a code, decodes the entry, then on + // the next iteration adds table[oldCode] + first(entry) to + // the table, increments nextCode, and bumps codeSize when + // nextCode exceeds the current code mask. That bump rule + // (nextCode > 2^codeSize - 1) is one off from the + // `code_size = ceil(log2(next_code))` rule in the GIF89a + // reference, but it is the rule real-world decoders use + // because it cancels the encoder's first-iteration add. + function decodeGif(bytes: Uint8Array): number[] { + const packed = bytes[10]; + const gctSize = (packed & 0x80) ? 3 * (1 << ((packed & 0x07) + 1)) : 0; + let pos = 13 + gctSize; + // Skip extensions until the Image Descriptor. + while (pos < bytes.length && bytes[pos] !== 0x2c) { + if (bytes[pos] === 0x21) { + pos++; + pos++; + while (pos < bytes.length) { + const size = bytes[pos++]; + if (size === 0) break; + pos += size; + } + } else { + break; + } + } + pos += 10; // Image Descriptor (separator + left + top + w + h + packed) + const minCodeSize = bytes[pos++]; + // Collect LZW sub-blocks into a flat byte array. + const data: number[] = []; + while (pos < bytes.length) { + const size = bytes[pos++]; + if (size === 0) break; + for (let i = 0; i < size; i++) data.push(bytes[pos++]); + } + const clearCode = 1 << minCodeSize; + const endCode = clearCode + 1; + const initCodeSize = minCodeSize + 1; + let codeSize = initCodeSize; + let nextCode = endCode + 1; + const codeMask = (cs: number): number => (1 << cs) - 1; + const table = new Map(); + for (let i = 0; i < clearCode; i++) table.set(i, [i]); + let bitBuffer = 0; + let bitCount = 0; + let dataIdx = 0; + const readBits = (n: number): number => { + while (bitCount < n) { + if (dataIdx >= data.length) return -1; + bitBuffer |= data[dataIdx++] << bitCount; + bitCount += 8; + } + const v = bitBuffer & codeMask(n); + bitBuffer >>>= n; + bitCount -= n; + return v; + }; + const out: number[] = []; + let oldCode: number | null = null; + let safety = 0; + while (safety++ < 1_000_000) { + const k = readBits(codeSize); + if (k === -1 || k === endCode) break; + if (k === clearCode) { + table.clear(); + for (let i = 0; i < clearCode; i++) table.set(i, [i]); + codeSize = initCodeSize; + nextCode = endCode + 1; + oldCode = null; + continue; + } + let entry: number[]; + if (table.has(k)) { + entry = table.get(k)!; + } else if (k === nextCode && oldCode !== null) { + const pe = table.get(oldCode)!; + entry = [...pe, pe[0]]; + } else { + throw new Error(`bad code ${k} at nextCode=${nextCode}, codeSize=${codeSize}`); + } + out.push(...entry); + if (oldCode !== null) { + const pe = table.get(oldCode)!; + table.set(nextCode, [...pe, entry[0]]); + nextCode++; + if (nextCode > codeMask(codeSize) && codeSize < 12) { + codeSize++; + } + } + oldCode = k; + } + return out; + } + + function rgbToIndex(r: number, g: number, b: number): number { + const rq = r === 0 ? 0 : Math.round(r / 51); + const gq = g === 0 ? 0 : Math.round(g / 51); + const bq = b === 0 ? 0 : Math.round(b / 51); + return rq * 36 + gq * 6 + bq; + } + + // 600 varied pixels — crosses the 256-entry table boundary and + // exercises the 9→10 code-size transition, plus a second + // gradient sweep that crosses the 10→11 transition. + const w = 600; + const h = 1; + const rgba = new Uint8ClampedArray(w * h * 4); + for (let i = 0; i < w; i++) { + rgba[i * 4] = (i * 17) % 256; + rgba[i * 4 + 1] = (i * 31) % 256; + rgba[i * 4 + 2] = (i * 47) % 256; + rgba[i * 4 + 3] = 255; + } + const bytes = encodeGif(rgba, w, h); + const decoded = decodeGif(bytes); + + const expected: number[] = []; + for (let i = 0; i < w; i++) { + expected.push(rgbToIndex(rgba[i * 4], rgba[i * 4 + 1], rgba[i * 4 + 2])); + } + expect(decoded).toEqual(expected); + }); +}); \ No newline at end of file diff --git a/packages/core/tests/validation.test.ts b/packages/core/tests/validation.test.ts index 0249f44..4e1bb69 100644 --- a/packages/core/tests/validation.test.ts +++ b/packages/core/tests/validation.test.ts @@ -105,7 +105,7 @@ describe('validateManifest', () => { ...validManifest, requests: [{ ...validManifest.requests[0], - assets: [{ ...validManifest.requests[0].assets[0], format: 'gif' }], + assets: [{ ...validManifest.requests[0].assets[0], format: 'tiff' }], }], }); expect(result.valid).toBe(false); @@ -172,6 +172,80 @@ describe('validateManifest', () => { }); expect(result.valid).toBe(false); }); + + it('accepts builder_recipe on image, tileset, and ui_panel assets', () => { + // The schema should permit builder_recipe on every image-style + // asset kind that generateJob can actually render through the + // recipe layer stack. + const recipe = { + canvasMode: 'compact', + layers: [ + { id: '1', type: 'rect', name: 'bg', visible: true, locked: false }, + ], + }; + for (const kind of ['image', 'tileset', 'ui_panel'] as const) { + const result = validateManifest({ + schemaVersion: 1, + requests: [{ + assets: [{ + kind, + name: `${kind}_with_recipe`, + format: 'png', + output_path: 'art', + width: 32, + height: 32, + builder_recipe: recipe, + ...(kind === 'tileset' ? { tile_width: 16, tile_height: 16 } : {}), + }], + }], + }); + if (!result.valid) { + // eslint-disable-next-line no-console + console.error(`${kind} + builder_recipe validation errors:`, result.errors); + } + expect(result.valid).toBe(true); + } + }); + + it('rejects builder_recipe on sprite_sheet assets', () => { + // Regression for Greptile round 13: the schema previously + // listed builder_recipe as a valid optional property of + // spriteSheetAsset, but generateJob unconditionally rejects + // this combination (the recipe renders one still image while + // the animation sidecar would still claim rows × columns + // frames, producing mismatched artifacts). The schema should + // reject the combination at validation time so callers get + // a clear schema error instead of a runtime asset error. + const result = validateManifest({ + schemaVersion: 1, + requests: [{ + assets: [{ + kind: 'sprite_sheet', + name: 'sprites', + format: 'png', + output_path: 'art', + width: 64, + height: 64, + frame_width: 16, + frame_height: 16, + rows: 2, + columns: 2, + builder_recipe: { + canvasMode: 'compact', + layers: [ + { id: '1', type: 'rect', name: 'bg', visible: true, locked: false }, + ], + }, + }], + }], + }); + expect(result.valid).toBe(false); + // The error must mention builder_recipe as an unknown property. + const messages = result.errors + .map((e) => `${e.path}: ${e.message} ${JSON.stringify(e.params ?? {})}`) + .join('\n'); + expect(messages).toMatch(/builder_recipe/); + }); }); describe('validateBuilderRecipe', () => { diff --git a/packages/schemas/src/manifest.schema.json b/packages/schemas/src/manifest.schema.json index eb9fdb6..d617404 100644 --- a/packages/schemas/src/manifest.schema.json +++ b/packages/schemas/src/manifest.schema.json @@ -58,7 +58,7 @@ "baseAsset": { "type": "object", "required": ["kind", "name", "format", "output_path"], - "description": "Common fields for every asset kind. Image-style assets also require width/height; audio does not.", + "description": "Common fields for every asset kind. Image-style assets also require width/height; audio does not. Concrete asset schemas repeat these properties so additionalProperties:false can reject unknown fields per-kind.", "properties": { "kind": { "type": "string" }, "name": { "type": "string" }, @@ -82,84 +82,135 @@ } }, "imageAsset": { - "allOf": [ - { "$ref": "#/definitions/baseAsset" }, - { - "type": "object", - "required": ["kind", "width", "height"], - "properties": { - "kind": { "const": "image" }, - "format": { "type": "string", "enum": ["png", "jpg", "jpeg", "webp"] } - } - } - ] + "type": "object", + "required": ["kind", "name", "width", "height", "format", "output_path"], + "properties": { + "kind": { "const": "image" }, + "name": { "type": "string" }, + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 }, + "format": { "type": "string", "enum": ["png", "jpg", "jpeg", "webp", "bmp", "gif"] }, + "output_path": { + "type": "string", + "pattern": "^(?!/|.*\\.\\.)[a-zA-Z0-9_\\-./]*$", + "description": "Output directory inside the ZIP. Letters, digits, underscore, hyphen, dot, and forward slash only. Must not start with '/' and must not contain '..'." + }, + "label_enabled": { "type": "boolean" }, + "numbering_style": { "type": "string", "enum": ["zero-padded", "plain", "none"] }, + "background_color": { "type": "string" }, + "fill_mode": { "type": "string", "enum": ["repeat", "stretch"] }, + "label_position": { "type": "string", "enum": ["corners", "center", "top-center", "bottom-center"] }, + "custom_fill_image": { "type": "string" }, + "builder_recipe": { "$ref": "https://placeholderer.dev/schemas/builder-recipe/v1" } + }, + "additionalProperties": false }, "spriteSheetAsset": { - "allOf": [ - { "$ref": "#/definitions/baseAsset" }, - { - "type": "object", - "required": ["kind", "width", "height", "frame_width", "frame_height", "rows", "columns"], - "properties": { - "kind": { "const": "sprite_sheet" }, - "format": { "type": "string", "enum": ["png", "jpg", "jpeg", "webp"] }, - "frame_width": { "type": "integer", "minimum": 1 }, - "frame_height": { "type": "integer", "minimum": 1 }, - "rows": { "type": "integer", "minimum": 1 }, - "columns": { "type": "integer", "minimum": 1 }, - "show_grid": { "type": "boolean", "default": true }, - "frame_duration_ms": { "type": "number", "minimum": 1, "maximum": 10000, "description": "Per-frame duration in ms; when set, the generator emits an animation.json sidecar" } - } - } - ] + "type": "object", + "required": ["kind", "name", "width", "height", "format", "output_path", "frame_width", "frame_height", "rows", "columns"], + "properties": { + "kind": { "const": "sprite_sheet" }, + "name": { "type": "string" }, + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 }, + "format": { "type": "string", "enum": ["png", "jpg", "jpeg", "webp", "bmp", "gif"] }, + "output_path": { + "type": "string", + "pattern": "^(?!/|.*\\.\\.)[a-zA-Z0-9_\\-./]*$", + "description": "Output directory inside the ZIP. Letters, digits, underscore, hyphen, dot, and forward slash only. Must not start with '/' and must not contain '..'." + }, + "label_enabled": { "type": "boolean" }, + "numbering_style": { "type": "string", "enum": ["zero-padded", "plain", "none"] }, + "background_color": { "type": "string" }, + "fill_mode": { "type": "string", "enum": ["repeat", "stretch"] }, + "label_position": { "type": "string", "enum": ["corners", "center", "top-center", "bottom-center"] }, + "custom_fill_image": { "type": "string" }, + "frame_width": { "type": "integer", "minimum": 1 }, + "frame_height": { "type": "integer", "minimum": 1 }, + "rows": { "type": "integer", "minimum": 1 }, + "columns": { "type": "integer", "minimum": 1 }, + "show_grid": { "type": "boolean", "default": true }, + "frame_duration_ms": { "type": "number", "minimum": 1, "maximum": 10000, "description": "Per-frame duration in ms; when set, the generator emits an animation.json sidecar" } + }, + "additionalProperties": false }, "tilesetAsset": { - "allOf": [ - { "$ref": "#/definitions/baseAsset" }, - { - "type": "object", - "required": ["kind", "width", "height", "tile_width", "tile_height"], - "properties": { - "kind": { "const": "tileset" }, - "format": { "type": "string", "enum": ["png", "jpg", "jpeg", "webp"] }, - "tile_width": { "type": "integer", "minimum": 1 }, - "tile_height": { "type": "integer", "minimum": 1 } - } - } - ] + "type": "object", + "required": ["kind", "name", "width", "height", "format", "output_path", "tile_width", "tile_height"], + "properties": { + "kind": { "const": "tileset" }, + "name": { "type": "string" }, + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 }, + "format": { "type": "string", "enum": ["png", "jpg", "jpeg", "webp", "bmp", "gif"] }, + "output_path": { + "type": "string", + "pattern": "^(?!/|.*\\.\\.)[a-zA-Z0-9_\\-./]*$", + "description": "Output directory inside the ZIP. Letters, digits, underscore, hyphen, dot, and forward slash only. Must not start with '/' and must not contain '..'." + }, + "label_enabled": { "type": "boolean" }, + "numbering_style": { "type": "string", "enum": ["zero-padded", "plain", "none"] }, + "background_color": { "type": "string" }, + "fill_mode": { "type": "string", "enum": ["repeat", "stretch"] }, + "label_position": { "type": "string", "enum": ["corners", "center", "top-center", "bottom-center"] }, + "custom_fill_image": { "type": "string" }, + "tile_width": { "type": "integer", "minimum": 1 }, + "tile_height": { "type": "integer", "minimum": 1 }, + "builder_recipe": { "$ref": "https://placeholderer.dev/schemas/builder-recipe/v1" } + }, + "additionalProperties": false }, "uiPanelAsset": { - "allOf": [ - { "$ref": "#/definitions/baseAsset" }, - { - "type": "object", - "required": ["kind", "width", "height"], - "properties": { - "kind": { "const": "ui_panel" }, - "format": { "type": "string", "enum": ["png", "jpg", "jpeg", "webp"] }, - "frame_style": { "type": "string", "enum": ["simple", "beveled", "inset", "outlined"] }, - "panel_guides": { "type": "boolean", "default": false }, - "export_panel_metadata": { "type": "boolean", "default": false } - } - } - ] + "type": "object", + "required": ["kind", "name", "width", "height", "format", "output_path"], + "properties": { + "kind": { "const": "ui_panel" }, + "name": { "type": "string" }, + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 }, + "format": { "type": "string", "enum": ["png", "jpg", "jpeg", "webp", "bmp", "gif"] }, + "output_path": { + "type": "string", + "pattern": "^(?!/|.*\\.\\.)[a-zA-Z0-9_\\-./]*$", + "description": "Output directory inside the ZIP. Letters, digits, underscore, hyphen, dot, and forward slash only. Must not start with '/' and must not contain '..'." + }, + "label_enabled": { "type": "boolean" }, + "numbering_style": { "type": "string", "enum": ["zero-padded", "plain", "none"] }, + "background_color": { "type": "string" }, + "fill_mode": { "type": "string", "enum": ["repeat", "stretch"] }, + "label_position": { "type": "string", "enum": ["corners", "center", "top-center", "bottom-center"] }, + "custom_fill_image": { "type": "string" }, + "frame_style": { "type": "string", "enum": ["simple", "beveled", "inset", "outlined"] }, + "panel_guides": { "type": "boolean", "default": false }, + "export_panel_metadata": { "type": "boolean", "default": false }, + "builder_recipe": { "$ref": "https://placeholderer.dev/schemas/builder-recipe/v1" } + }, + "additionalProperties": false }, "audioAsset": { - "allOf": [ - { "$ref": "#/definitions/baseAsset" }, - { - "type": "object", - "required": ["kind", "frequency", "duration"], - "properties": { - "kind": { "const": "audio" }, - "format": { "type": "string", "enum": ["wav"] }, - "frequency": { "type": "number", "minimum": 1, "maximum": 22050, "description": "Tone frequency in Hz" }, - "duration": { "type": "number", "minimum": 0.01, "maximum": 60, "description": "Duration in seconds" }, - "sample_rate": { "type": "integer", "minimum": 8000, "maximum": 96000, "default": 44100 }, - "amplitude": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5 } - } - } - ] + "type": "object", + "required": ["kind", "name", "format", "output_path", "frequency", "duration"], + "properties": { + "kind": { "const": "audio" }, + "name": { "type": "string" }, + "format": { "type": "string", "enum": ["wav"] }, + "output_path": { + "type": "string", + "pattern": "^(?!/|.*\\.\\.)[a-zA-Z0-9_\\-./]*$", + "description": "Output directory inside the ZIP. Letters, digits, underscore, hyphen, dot, and forward slash only. Must not start with '/' and must not contain '..'." + }, + "label_enabled": { "type": "boolean" }, + "numbering_style": { "type": "string", "enum": ["zero-padded", "plain", "none"] }, + "background_color": { "type": "string" }, + "fill_mode": { "type": "string", "enum": ["repeat", "stretch"] }, + "label_position": { "type": "string", "enum": ["corners", "center", "top-center", "bottom-center"] }, + "custom_fill_image": { "type": "string" }, + "frequency": { "type": "number", "minimum": 1, "maximum": 22050, "description": "Tone frequency in Hz" }, + "duration": { "type": "number", "minimum": 0.01, "maximum": 60, "description": "Duration in seconds" }, + "sample_rate": { "type": "integer", "minimum": 8000, "maximum": 96000, "default": 44100 }, + "amplitude": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5 } + }, + "additionalProperties": false } } } diff --git a/packages/schemas/src/types.ts b/packages/schemas/src/types.ts index 96db6e1..1aa9d5f 100644 --- a/packages/schemas/src/types.ts +++ b/packages/schemas/src/types.ts @@ -4,7 +4,7 @@ export type AssetKind = 'image' | 'sprite_sheet' | 'tileset' | 'ui_panel' | 'audio'; -export type Format = 'png' | 'jpg' | 'jpeg' | 'webp' | 'wav'; +export type Format = 'png' | 'jpg' | 'jpeg' | 'webp' | 'bmp' | 'gif' | 'wav'; export type NumberingStyle = 'zero-padded' | 'plain' | 'none'; @@ -70,6 +70,10 @@ export interface DimensionalAsset extends BaseAsset { export interface ImageAsset extends DimensionalAsset { kind: 'image'; + /** Optional UI Builder recipe. When present, generateJob renders + * the asset by feeding the recipe through the canvas renderer + * instead of the standard placeholder grid. */ + builder_recipe?: BuilderRecipe; } export interface SpriteSheetAsset extends DimensionalAsset { @@ -82,12 +86,18 @@ export interface SpriteSheetAsset extends DimensionalAsset { /** Per-frame duration in milliseconds. When set, the generator * writes an animation.json sidecar with the timing data. */ frame_duration_ms?: number; + // Note: sprite_sheet intentionally does NOT accept builder_recipe. + // A recipe renders a single still image but the animation sidecar + // would still claim rows × columns frames, producing a mismatched + // artifact set. generateJob rejects this combination. } export interface TilesetAsset extends DimensionalAsset { kind: 'tileset'; tile_width: number; tile_height: number; + /** Optional UI Builder recipe. */ + builder_recipe?: BuilderRecipe; } export interface UiPanelAsset extends DimensionalAsset { @@ -95,6 +105,8 @@ export interface UiPanelAsset extends DimensionalAsset { frame_style?: FrameStyle; panel_guides?: boolean; export_panel_metadata?: boolean; + /** Optional UI Builder recipe. */ + builder_recipe?: BuilderRecipe; } export interface AudioAsset extends BaseAsset { diff --git a/tests/e2e/placeholderer.spec.ts b/tests/e2e/placeholderer.spec.ts index 24a09e0..f101966 100644 --- a/tests/e2e/placeholderer.spec.ts +++ b/tests/e2e/placeholderer.spec.ts @@ -120,4 +120,34 @@ test.describe('Placeholderer web app', () => { const staleReport = page.locator('strong', { hasText: 'Manifest report' }); await expect(staleReport).toHaveCount(0); }); + + test('UI Builder presets are hidden behind a Presets button', async ({ page }) => { + // Regression for tier 4: engine-aware presets used to be + // visible inline in the right sidebar, which made it too easy + // to accidentally click one and trample in-progress work. + // They're now hidden behind a Presets button next to Clear; + // clicking it opens a popover. + await page.goto('/'); + await page.getByRole('heading', { name: 'Import Manifest' }).waitFor(); + + // Open the UI Builder. + await page.getByRole('button', { name: 'UI Builder' }).click(); + await page.getByRole('heading', { name: 'UI Builder' }).waitFor(); + + // The popover should NOT be open initially — no preset names + // are visible inline. + const dialog = page.locator('div[role="dialog"][aria-label="Engine-aware presets"]'); + await expect(dialog).toHaveCount(0); + + // Click the Presets button to open the popover. + await page.getByRole('button', { name: 'Presets' }).click(); + await expect(dialog).toBeVisible(); + await expect(page.getByRole('button', { name: 'Dialog Window' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Health Bar' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Crosshair' })).toBeVisible(); + + // Click outside to dismiss. + await page.locator('h2', { hasText: 'UI Builder' }).click(); + await expect(dialog).toHaveCount(0); + }); });