Spaces:
Running
Running
Download src/layout.js from NagaYu/dasumae-frame: direct link, hf CLI and curl.
- Browser
- Download file 10.8 kB
-
https://huggingface.co/spaces/NagaYu/dasumae-frame/resolve/main/src/layout.js
- Command line
-
hf download hf://spaces/NagaYu/dasumae-frame/src/layout.js
-
curl -L -o layout.js https://huggingface.co/spaces/NagaYu/dasumae-frame/resolve/main/src/layout.js
10.8 kB
| // DasuMae Frame β layout engine (pure geometry, no DOM, no dependencies). | |
| // | |
| // Single source of truth for where the screenshot(s) are drawn on the export | |
| // canvas. The live preview and the export renderer both call the same | |
| // functions here so what you see is exactly what you get. | |
| // | |
| // Model | |
| // ----- | |
| // 1. The canvas is canvasW Γ canvasH device pixels (an export preset size, | |
| // e.g. 1600Γ900, or the Original fit size). | |
| // 2. Padding insets the canvas on all four sides, leaving the *content rect* | |
| // β the area the image(s) are allowed to occupy. Padding per side is | |
| // pad = (paddingPct / 100) * (min(canvasW, canvasH) / 2) | |
| // i.e. paddingPct is a fraction of the shorter side split across the two | |
| // opposite edges. It is clamped so the content rect never collapses below | |
| // MIN_CONTENT px on either axis. | |
| // 3. The content rect is split into *cells*: 1 cell in single mode (today's | |
| // behaviour, byte-for-byte unchanged), or 2 cells in 2-up ("duo") mode β | |
| // columns (left/right) or rows (top/bottom) β separated by a gap (px at | |
| // export scale). The gap lives only *between* cells; the padding still | |
| // sits outside, so the outer edges are never pushed in by the gap and the | |
| // gap is never doubled at the canvas edge. See cellRects(). | |
| // 4. Each cell runs today's per-image math independently (placeInRect): the | |
| // image is fitted into the cell rect with "contain" semantics | |
| // (baseScale = min(cellW/imageW, cellH/imageH)), then multiplied by the | |
| // user scale. drawnW/H = imageW/H * baseScale * scale. | |
| // 5. The image is centred in its cell, then shifted by the drag offset | |
| // (offsetX, offsetY) in canvas pixels. The offset is clamped to that | |
| // cell (see below) and the *clamped* value is returned. | |
| // | |
| // Offset clamp rule (documented, one rule for both regimes) | |
| // --------------------------------------------------------- | |
| // On each axis independently let | |
| // limit = |contentSize - drawnSize| / 2 | |
| // and clamp the offset to [-limit, +limit]. | |
| // β’ Zoomed in (drawnSize >= contentSize): limit = (drawn - content)/2. | |
| // The image always fully covers the content rect; an edge can travel up | |
| // to the matching content edge but never past it, so dragging never | |
| // exposes a background gap *inside* the content area. | |
| // β’ Fitted (drawnSize < contentSize): limit = (content - drawn)/2. | |
| // There is intended padding slack; the image may slide until one of its | |
| // edges meets a content edge, but no further β so it can be repositioned | |
| // without being flung out and leaving an absurd gap. | |
| // When drawnSize == contentSize the limit is 0 (no travel), which is correct. | |
| export const MIN_CONTENT = 1; // px β content rect never smaller than this | |
| /** Clamp a number to [lo, hi]. */ | |
| export function clamp(v, lo, hi) { | |
| if (hi < lo) return lo; | |
| return v < lo ? lo : v > hi ? hi : v; | |
| } | |
| /** | |
| * Content rect (the padded area the image lives in) for a canvas. | |
| * @returns {{x:number,y:number,w:number,h:number,pad:number}} | |
| */ | |
| export function contentRect(canvasW, canvasH, paddingPct) { | |
| const W = Math.max(0, canvasW); | |
| const H = Math.max(0, canvasH); | |
| const pct = clamp(paddingPct, 0, 100); | |
| let pad = (pct / 100) * (Math.min(W, H) / 2); | |
| // Keep at least MIN_CONTENT on each axis. | |
| const maxPad = Math.max(0, (Math.min(W, H) - MIN_CONTENT) / 2); | |
| pad = clamp(pad, 0, maxPad); | |
| return { x: pad, y: pad, w: W - 2 * pad, h: H - 2 * pad, pad }; | |
| } | |
| /** "contain" fit scale of an image into a box. */ | |
| export function fitScale(boxW, boxH, imageW, imageH) { | |
| if (imageW <= 0 || imageH <= 0 || boxW <= 0 || boxH <= 0) return 0; | |
| return Math.min(boxW / imageW, boxH / imageH); | |
| } | |
| /** Per-axis max offset magnitude for the clamp rule above. */ | |
| export function offsetLimit(contentSize, drawnSize) { | |
| return Math.abs(contentSize - drawnSize) / 2; | |
| } | |
| /** | |
| * Place one image inside an arbitrary rectangle ("contain" fit + user scale + | |
| * clamped drag offset). This is today's per-image math, factored out so a | |
| * single cell (single mode) and each of two cells (2-up) share exactly the | |
| * same geometry β the clamp is always relative to the rect it is given. | |
| * | |
| * @param {{x:number,y:number,w:number,h:number}} rect the cell the image lives in | |
| * @param {number} imageW source image width (px) | |
| * @param {number} imageH source image height (px) | |
| * @param {number} [scale=1] user scale multiplier (>0), applied on top of fit | |
| * @param {number} [offsetX=0] drag offset X (px, before clamp) | |
| * @param {number} [offsetY=0] drag offset Y (px, before clamp) | |
| * @returns {{ | |
| * image:{x:number,y:number,w:number,h:number}, | |
| * baseScale:number, effectiveScale:number, | |
| * offset:{x:number,y:number}, limit:{x:number,y:number} | |
| * }} | |
| */ | |
| export function placeInRect(rect, imageW, imageH, scale, offsetX, offsetY) { | |
| const iW = Math.max(0, imageW || 0); | |
| const iH = Math.max(0, imageH || 0); | |
| const s = scale == null ? 1 : Math.max(0, scale); | |
| const oX = offsetX || 0; | |
| const oY = offsetY || 0; | |
| // Degenerate image: nothing to draw (anchored at the cell's top-left). | |
| if (iW === 0 || iH === 0) { | |
| return { | |
| image: { x: rect.x, y: rect.y, w: 0, h: 0 }, | |
| baseScale: 0, | |
| effectiveScale: 0, | |
| offset: { x: 0, y: 0 }, | |
| limit: { x: 0, y: 0 }, | |
| }; | |
| } | |
| const baseScale = fitScale(rect.w, rect.h, iW, iH); | |
| const effectiveScale = baseScale * s; | |
| const drawnW = iW * effectiveScale; | |
| const drawnH = iH * effectiveScale; | |
| const limitX = offsetLimit(rect.w, drawnW); | |
| const limitY = offsetLimit(rect.h, drawnH); | |
| const clampedX = clamp(oX, -limitX, limitX); | |
| const clampedY = clamp(oY, -limitY, limitY); | |
| // Centre of the cell. | |
| const cx = rect.x + rect.w / 2; | |
| const cy = rect.y + rect.h / 2; | |
| return { | |
| image: { | |
| x: cx - drawnW / 2 + clampedX, | |
| y: cy - drawnH / 2 + clampedY, | |
| w: drawnW, | |
| h: drawnH, | |
| }, | |
| baseScale, | |
| effectiveScale, | |
| offset: { x: clampedX, y: clampedY }, | |
| limit: { x: limitX, y: limitY }, | |
| }; | |
| } | |
| /** | |
| * Split a content rect into cells. | |
| * β’ single β one cell equal to the whole content rect. | |
| * β’ duo / columns β left + right cells, each (contentW β gap)/2 wide, | |
| * full content height, with `gap` px between them. | |
| * β’ duo / rows β top + bottom cells, each (contentH β gap)/2 tall, | |
| * full content width, with `gap` px between them. | |
| * The gap sits ONLY between the two cells; the padding stays outside, so the | |
| * outer edges are the content-rect edges (never inset by the gap) and the gap | |
| * is never doubled at the canvas edge. The gap is clamped so neither cell | |
| * collapses below MIN_CONTENT on the split axis. | |
| * | |
| * @returns {Array<{x:number,y:number,w:number,h:number}>} | |
| */ | |
| export function cellRects(content, mode = 'single', orientation = 'columns', gap = 0) { | |
| if (mode !== 'duo') { | |
| return [{ x: content.x, y: content.y, w: content.w, h: content.h }]; | |
| } | |
| const g0 = Math.max(0, gap || 0); | |
| if (orientation === 'rows') { | |
| const g = clamp(g0, 0, Math.max(0, content.h - 2 * MIN_CONTENT)); | |
| const cellH = (content.h - g) / 2; | |
| return [ | |
| { x: content.x, y: content.y, w: content.w, h: cellH }, | |
| { x: content.x, y: content.y + cellH + g, w: content.w, h: cellH }, | |
| ]; | |
| } | |
| const g = clamp(g0, 0, Math.max(0, content.w - 2 * MIN_CONTENT)); | |
| const cellW = (content.w - g) / 2; | |
| return [ | |
| { x: content.x, y: content.y, w: cellW, h: content.h }, | |
| { x: content.x + cellW + g, y: content.y, w: cellW, h: content.h }, | |
| ]; | |
| } | |
| /** | |
| * Compute the drawn image rectangle on the export canvas (single image). | |
| * Thin wrapper over contentRect() + placeInRect() β its result is byte-for-byte | |
| * identical to the original single-image engine, so Phase 1 is unchanged. | |
| * | |
| * @param {object} p | |
| * @param {number} p.canvasW export/preview canvas width (px) | |
| * @param {number} p.canvasH export/preview canvas height (px) | |
| * @param {number} p.imageW source image width (px) | |
| * @param {number} p.imageH source image height (px) | |
| * @param {number} [p.paddingPct=0] padding as % of shorter side (0..100) | |
| * @param {number} [p.scale=1] user scale multiplier (>0), applied on top of fit | |
| * @param {number} [p.offsetX=0] drag offset X (px, before clamp) | |
| * @param {number} [p.offsetY=0] drag offset Y (px, before clamp) | |
| * @returns {{ | |
| * content:{x:number,y:number,w:number,h:number,pad:number}, | |
| * image:{x:number,y:number,w:number,h:number}, | |
| * baseScale:number, | |
| * effectiveScale:number, | |
| * offset:{x:number,y:number}, | |
| * limit:{x:number,y:number} | |
| * }} | |
| */ | |
| export function computeLayout(p) { | |
| const canvasW = Math.max(0, p.canvasW || 0); | |
| const canvasH = Math.max(0, p.canvasH || 0); | |
| const paddingPct = p.paddingPct == null ? 0 : p.paddingPct; | |
| const content = contentRect(canvasW, canvasH, paddingPct); | |
| const placed = placeInRect(content, p.imageW, p.imageH, p.scale, p.offsetX, p.offsetY); | |
| return { content, ...placed }; | |
| } | |
| /** | |
| * Compute the full composite layout: content rect β cells β per-cell image | |
| * placement. Single mode returns exactly one cell (identical to computeLayout). | |
| * | |
| * @param {object} p | |
| * @param {number} p.canvasW | |
| * @param {number} p.canvasH | |
| * @param {number} [p.paddingPct=0] | |
| * @param {'single'|'duo'} [p.mode='single'] | |
| * @param {'columns'|'rows'} [p.orientation='columns'] (duo only) | |
| * @param {number} [p.gap=0] px at export scale, between the two cells (duo only) | |
| * @param {Array<{imageW:number,imageH:number,scale?:number,offsetX?:number,offsetY?:number}>} [p.images=[]] | |
| * @returns {{ | |
| * content:{x:number,y:number,w:number,h:number,pad:number}, | |
| * mode:'single'|'duo', orientation:'columns'|'rows', gap:number, | |
| * cells: Array<{ | |
| * cell:{x:number,y:number,w:number,h:number}, | |
| * image:{x:number,y:number,w:number,h:number}, | |
| * baseScale:number, effectiveScale:number, | |
| * offset:{x:number,y:number}, limit:{x:number,y:number} | |
| * }> | |
| * }} | |
| */ | |
| export function computeComposite(p) { | |
| const canvasW = Math.max(0, p.canvasW || 0); | |
| const canvasH = Math.max(0, p.canvasH || 0); | |
| const paddingPct = p.paddingPct == null ? 0 : p.paddingPct; | |
| const content = contentRect(canvasW, canvasH, paddingPct); | |
| const mode = p.mode === 'duo' ? 'duo' : 'single'; | |
| const orientation = p.orientation === 'rows' ? 'rows' : 'columns'; | |
| const gap = mode === 'duo' ? Math.max(0, p.gap || 0) : 0; | |
| const rects = cellRects(content, mode, orientation, gap); | |
| const images = p.images || []; | |
| const cells = rects.map((rect, i) => { | |
| const im = images[i] || {}; | |
| const placed = placeInRect(rect, im.imageW, im.imageH, im.scale, im.offsetX, im.offsetY); | |
| return { cell: rect, ...placed }; | |
| }); | |
| return { content, mode, orientation, gap, cells }; | |
| } | |