Ctrl+V always nudged the original clipboard by the same amount, so a second paste landed on the first copy.
412 lines
14 KiB
JavaScript
412 lines
14 KiB
JavaScript
/** Pure region layout helpers for Ideogram 4 captions. No DOM. */
|
||
|
||
export function finiteCoord(value) {
|
||
if (typeof value === "boolean" || value == null) return null;
|
||
if (typeof value === "string") {
|
||
if (value.trim() === "") return null;
|
||
} else if (typeof value !== "number") {
|
||
return null;
|
||
}
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
function clampBboxAxis(value) {
|
||
return Math.max(0, Math.min(1000, value));
|
||
}
|
||
|
||
/** Ideogram bbox is [ymin, xmin, ymax, xmax] in 0-1000, origin top-left. */
|
||
export function ideogramBboxToRect(bbox) {
|
||
if (!Array.isArray(bbox) || bbox.length !== 4) return null;
|
||
const coords = bbox.map(finiteCoord);
|
||
if (coords.some((v) => v == null)) return null;
|
||
let [ymin, xmin, ymax, xmax] = coords;
|
||
if (ymin > ymax) [ymin, ymax] = [ymax, ymin];
|
||
if (xmin > xmax) [xmin, xmax] = [xmax, xmin];
|
||
ymin = clampBboxAxis(ymin);
|
||
xmin = clampBboxAxis(xmin);
|
||
ymax = clampBboxAxis(ymax);
|
||
xmax = clampBboxAxis(xmax);
|
||
return {
|
||
x: xmin / 1000,
|
||
y: ymin / 1000,
|
||
w: (xmax - xmin) / 1000,
|
||
h: (ymax - ymin) / 1000,
|
||
};
|
||
}
|
||
|
||
function scaleAxis(value) {
|
||
if (value >= 1) return 1000;
|
||
if (value <= 0) return 0;
|
||
return Math.max(0, Math.min(1000, Math.round(value * 1000)));
|
||
}
|
||
|
||
/** Editor x/y/w/h (0-1) to Ideogram [ymin, xmin, ymax, xmax], or null if unusable. */
|
||
export function rectToIdeogramBbox(box) {
|
||
if (!box || typeof box !== "object") return null;
|
||
const x = finiteCoord(box.x ?? 0);
|
||
const y = finiteCoord(box.y ?? 0);
|
||
const w = finiteCoord(box.w ?? 0);
|
||
const h = finiteCoord(box.h ?? 0);
|
||
if (x == null || y == null || w == null || h == null) return null;
|
||
let ymin = scaleAxis(y);
|
||
let xmin = scaleAxis(x);
|
||
let ymax = scaleAxis(y + h);
|
||
let xmax = scaleAxis(x + w);
|
||
if (ymin > ymax) [ymin, ymax] = [ymax, ymin];
|
||
if (xmin > xmax) [xmin, xmax] = [xmax, xmin];
|
||
return [ymin, xmin, ymax, xmax];
|
||
}
|
||
|
||
export function fallbackRegion(index) {
|
||
const i = Number.isInteger(index) && index >= 0 ? index : 0;
|
||
const row = Math.floor(i / 4);
|
||
const col = i % 4;
|
||
return {
|
||
x: 0.02 + col * 0.25,
|
||
y: 0.02 + row * 0.17,
|
||
w: 0.22,
|
||
h: 0.14,
|
||
nobbox: true,
|
||
};
|
||
}
|
||
|
||
/** Caption text. Finite numbers are kept; other non-strings are dropped. */
|
||
export function captionText(value) {
|
||
if (typeof value === "string") return value;
|
||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||
return "";
|
||
}
|
||
|
||
/** One line for the canvas. Always a string, so drawing can wrap it. */
|
||
export function regionLabel(box) {
|
||
if (!box || typeof box !== "object") return "";
|
||
const desc = captionText(box.desc);
|
||
if (box.type === "text") {
|
||
const text = captionText(box.text);
|
||
if (text) return desc ? `"${text}" — ${desc}` : `"${text}"`;
|
||
}
|
||
return desc;
|
||
}
|
||
|
||
export function elementToRegion(el, idx) {
|
||
if (!el || typeof el !== "object") return null;
|
||
const box = {
|
||
type: el.type === "text" ? "text" : "obj",
|
||
text: captionText(el.text),
|
||
desc: captionText(el.desc),
|
||
// Ideogram 4 allows at most five uppercase #RRGGBB colors on an element.
|
||
// Shorthand and other values are dropped here so the editor stores the
|
||
// same colors the prompt will keep.
|
||
palette: cleanPalette(Array.isArray(el.color_palette) ? el.color_palette : [], 5),
|
||
};
|
||
const rect = ideogramBboxToRect(el.bbox);
|
||
if (rect) Object.assign(box, rect);
|
||
else Object.assign(box, fallbackRegion(idx));
|
||
return box;
|
||
}
|
||
|
||
export function regionCoordsFinite(b) {
|
||
return !!b && [b.x, b.y, b.w, b.h].every((n) => typeof n === "number" && Number.isFinite(n));
|
||
}
|
||
|
||
function hitsRegion(mx, my, b, rx, ry) {
|
||
const x1 = b.x;
|
||
const y1 = b.y;
|
||
const x2 = b.x + b.w;
|
||
const y2 = b.y + b.h;
|
||
const near = (cx, cy) => Math.abs(mx - cx) < rx && Math.abs(my - cy) < ry;
|
||
if (near(x1, y1) || near(x2, y1) || near(x1, y2) || near(x2, y2)) return true;
|
||
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < ry) return true;
|
||
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < ry) return true;
|
||
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < rx) return true;
|
||
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < rx) return true;
|
||
return mx >= x1 && mx <= x2 && my >= y1 && my <= y2;
|
||
}
|
||
|
||
/**
|
||
* After every selected region is deleted, nothing stays active.
|
||
* Leaving the nearest survivor active made the next Delete remove it too.
|
||
*/
|
||
export function indexesAfterClearingSelection(count, selected) {
|
||
const total = Number.isInteger(count) && count > 0 ? count : 0;
|
||
const seen = new Set();
|
||
let removed = 0;
|
||
for (const i of selected || []) {
|
||
if (!Number.isInteger(i) || i < 0 || i >= total || seen.has(i)) continue;
|
||
seen.add(i);
|
||
removed += 1;
|
||
}
|
||
return { count: total - removed, selected: [], active: -1 };
|
||
}
|
||
|
||
/** Index of the topmost region under a normalized point, or -1. Later boxes are drawn above earlier ones. */
|
||
export function topRegionIndex(boxes, x, y, rx = 0, ry = 0) {
|
||
const list = boxes || [];
|
||
for (let i = list.length - 1; i >= 0; i--) {
|
||
const b = list[i];
|
||
if (!regionCoordsFinite(b)) continue;
|
||
if (hitsRegion(x, y, b, rx, ry)) return i;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
const HEX6 = /^#[0-9A-F]{6}$/;
|
||
const HEX3 = /^#[0-9A-F]{3}$/;
|
||
|
||
/** Ideogram 4 accepts uppercase #RRGGBB. #RGB is expanded; anything else is dropped. */
|
||
export function normalizeHex(color) {
|
||
if (typeof color !== "string") return null;
|
||
const text = color.trim().toUpperCase();
|
||
if (HEX6.test(text)) return text;
|
||
if (HEX3.test(text)) return "#" + text.slice(1).split("").map((ch) => ch + ch).join("");
|
||
return null;
|
||
}
|
||
|
||
export function cleanPalette(colors, limit) {
|
||
const out = [];
|
||
for (const color of colors || []) {
|
||
const hex = normalizeHex(color);
|
||
if (!hex) continue;
|
||
out.push(hex);
|
||
if (limit != null && out.length >= limit) break;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Lowercase #rrggbb for input[type=color]. Anything else is unusable there. */
|
||
export function swatchHex(color) {
|
||
const hex = normalizeHex(color);
|
||
return hex ? hex.toLowerCase() : null;
|
||
}
|
||
|
||
function clamp01(v) {
|
||
return Math.max(0, Math.min(1, v));
|
||
}
|
||
|
||
function normalizeBoxCoords(b) {
|
||
let x = b.x;
|
||
let y = b.y;
|
||
let w = b.w;
|
||
let h = b.h;
|
||
if (typeof x !== "number" || !Number.isFinite(x)) x = 0;
|
||
if (typeof y !== "number" || !Number.isFinite(y)) y = 0;
|
||
if (typeof w !== "number" || !Number.isFinite(w)) w = 0;
|
||
if (typeof h !== "number" || !Number.isFinite(h)) h = 0;
|
||
if (w < 0) { x += w; w = -w; }
|
||
if (h < 0) { y += h; h = -h; }
|
||
x = clamp01(x);
|
||
y = clamp01(y);
|
||
w = Math.min(w, 1 - x);
|
||
h = Math.min(h, 1 - y);
|
||
return { ...b, x, y, w: Math.max(0, w), h: Math.max(0, h) };
|
||
}
|
||
|
||
/** Move every region by the same nudge, then keep it inside the canvas. */
|
||
export function offsetAddedRegions(boxes, nudge = 0.05) {
|
||
return (boxes || []).map((b) => normalizeBoxCoords({
|
||
...b,
|
||
x: (typeof b.x === "number" && Number.isFinite(b.x) ? b.x : 0) + nudge,
|
||
y: (typeof b.y === "number" && Number.isFinite(b.y) ? b.y : 0) + nudge,
|
||
}));
|
||
}
|
||
|
||
/** Each Ctrl+V moves this far from the copied region. The next paste adds another step. */
|
||
export const PASTE_NUDGE = 0.03;
|
||
|
||
export function nextPasteNudge(previousPastes) {
|
||
const done = Number.isInteger(previousPastes) && previousPastes > 0 ? previousPastes : 0;
|
||
return PASTE_NUDGE * (done + 1);
|
||
}
|
||
|
||
/** Ctrl+V: nudge a copy so it is visible, and do not keep the source store link. */
|
||
export function pasteRegions(boxes, nudge = PASTE_NUDGE) {
|
||
return offsetAddedRegions(boxes, nudge).map((b) => {
|
||
const copy = {
|
||
...b,
|
||
palette: Array.isArray(b.palette) ? b.palette.slice() : [],
|
||
};
|
||
delete copy.storeRef;
|
||
return copy;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Place a character/object loaded into the scene.
|
||
* Indexes 0-16 keep the original 0.04 diagonal from (0.1, 0.1). Later indexes
|
||
* tile a 0.25x0.20 grid that stays inside the canvas.
|
||
*/
|
||
export function staggerLoadedRegion(index) {
|
||
const origin = 0.1;
|
||
const step = 0.04;
|
||
const w = 0.25;
|
||
const h = 0.2;
|
||
const i = Number.isInteger(index) && index >= 0 ? index : 0;
|
||
const diagonalCount = Math.floor((1 - w - origin) / step) + 1;
|
||
if (i < diagonalCount) {
|
||
return normalizeBoxCoords({
|
||
x: origin + i * step,
|
||
y: origin + i * step,
|
||
w,
|
||
h,
|
||
});
|
||
}
|
||
const extra = i - diagonalCount;
|
||
const cols = Math.max(1, Math.floor((1 - w) / w) + 1);
|
||
const rows = Math.max(1, Math.floor((1 - h) / h) + 1);
|
||
const slot = extra % (cols * rows);
|
||
const col = slot % cols;
|
||
const row = Math.floor(slot / cols);
|
||
return normalizeBoxCoords({ x: col * w, y: row * h, w, h });
|
||
}
|
||
|
||
const SCALE_ANCHORS = {
|
||
center: [0.5, 0.5],
|
||
"top-left": [0, 0],
|
||
top: [0.5, 0],
|
||
"top-right": [1, 0],
|
||
"center-left": [0, 0.5],
|
||
"center-right": [1, 0.5],
|
||
"bottom-left": [0, 1],
|
||
bottom: [0.5, 1],
|
||
"bottom-right": [1, 1],
|
||
};
|
||
|
||
/**
|
||
* Side length for the size fields and the width/height outputs.
|
||
* Round, clamp to 64-16384, then round to a multiple of 8 (the node step).
|
||
* Ties on a half step go to the larger multiple. An empty or non-positive
|
||
* value keeps the fallback, which the editor uses as 1024.
|
||
* The open model's multiple-of-16 rule is a separate note and does not
|
||
* change this number.
|
||
*/
|
||
export function snapLayoutDim(value, fallback = 1024) {
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n) || n <= 0) return fallback;
|
||
const clamped = Math.max(64, Math.min(16384, Math.round(n)));
|
||
const snapped = Math.round(clamped / 8) * 8;
|
||
return Math.max(64, Math.min(16384, snapped));
|
||
}
|
||
|
||
/**
|
||
* Empty when width and height suit the open Ideogram 4 model.
|
||
* Otherwise a note of that model's rule. The hosted API uses its own preset
|
||
* list, so this does not say a size is invalid there.
|
||
* https://github.com/ideogram-oss/ideogram4/blob/main/docs/inference.md
|
||
*/
|
||
export function ideogramResolutionWarning(width, height) {
|
||
const w = Number(width);
|
||
const h = Number(height);
|
||
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return "";
|
||
const multiple = Number.isInteger(w) && Number.isInteger(h) && w % 16 === 0 && h % 16 === 0;
|
||
const inRange = w >= 256 && h >= 256 && w <= 2048 && h <= 2048;
|
||
const ratio = w / h;
|
||
const ratioOk = ratio >= 1 / 6 && ratio <= 6;
|
||
if (multiple && inRange && ratioOk) return "";
|
||
return "Open Ideogram 4 model: multiples of 16, each side 256-2048, aspect at most 6:1";
|
||
}
|
||
|
||
export function dimsFromAspect(aspectStr) {
|
||
const m = String(aspectStr || "").trim().match(/^(\d+(?:\.\d+)?)\s*[:/x×]\s*(\d+(?:\.\d+)?)$/i);
|
||
if (!m) return null;
|
||
const aw = parseFloat(m[1]);
|
||
const ah = parseFloat(m[2]);
|
||
if (!(aw > 0) || !(ah > 0)) return null;
|
||
if (aw >= ah) return { w: 1024, h: Math.max(1, Math.round((1024 * ah) / aw)) };
|
||
return { w: Math.max(1, Math.round((1024 * aw) / ah)), h: 1024 };
|
||
}
|
||
|
||
function contentBoundsPx(boxes, layoutW, layoutH) {
|
||
let minX = Infinity;
|
||
let minY = Infinity;
|
||
let maxX = -Infinity;
|
||
let maxY = -Infinity;
|
||
let any = false;
|
||
for (const b of boxes || []) {
|
||
if (!regionCoordsFinite(b)) continue;
|
||
any = true;
|
||
minX = Math.min(minX, b.x);
|
||
minY = Math.min(minY, b.y);
|
||
maxX = Math.max(maxX, b.x + b.w);
|
||
maxY = Math.max(maxY, b.y + b.h);
|
||
}
|
||
if (!any || !(maxX > minX) || !(maxY > minY)) return null;
|
||
return {
|
||
minX: minX * layoutW,
|
||
minY: minY * layoutH,
|
||
maxX: maxX * layoutW,
|
||
maxY: maxY * layoutH,
|
||
w: (maxX - minX) * layoutW,
|
||
h: (maxY - minY) * layoutH,
|
||
};
|
||
}
|
||
|
||
function anchorPointOnRect(rect, mode) {
|
||
const [ax, ay] = SCALE_ANCHORS[mode] || [0.5, 0.5];
|
||
return [rect.minX + ax * rect.w, rect.minY + ay * rect.h];
|
||
}
|
||
|
||
function anchorPointOnCanvas(canvasW, canvasH, mode) {
|
||
const [ax, ay] = SCALE_ANCHORS[mode] || [0.5, 0.5];
|
||
return [ax * canvasW, ay * canvasH];
|
||
}
|
||
|
||
/** Uniform scale of region content. Blank margins are cropped; content is not enlarged. */
|
||
export function transformBoxesContentUniform(boxes, oldW, oldH, newW, newH, mode) {
|
||
const anchor = SCALE_ANCHORS[mode];
|
||
if (!anchor || oldW <= 0 || oldH <= 0 || newW <= 0 || newH <= 0) return boxes;
|
||
const content = contentBoundsPx(boxes, oldW, oldH);
|
||
if (!content) return boxes;
|
||
|
||
const s = Math.min(1, Math.min(newW / content.w, newH / content.h));
|
||
const anchorOld = anchorPointOnRect(content, mode);
|
||
const anchorNew = anchorPointOnCanvas(newW, newH, mode);
|
||
|
||
return boxes.map((box) => {
|
||
const ox = box.x * oldW;
|
||
const oy = box.y * oldH;
|
||
const ow = box.w * oldW;
|
||
const oh = box.h * oldH;
|
||
return normalizeBoxCoords({
|
||
...box,
|
||
x: (anchorNew[0] + s * (ox - anchorOld[0])) / newW,
|
||
y: (anchorNew[1] + s * (oy - anchorOld[1])) / newH,
|
||
w: (s * ow) / newW,
|
||
h: (s * oh) / newH,
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Map pasted regions from a source canvas onto destW x destH.
|
||
* "stretch" keeps normalized coordinates. An anchor mode pins a uniform fit.
|
||
* Scale mode "stretch" centers that fit: the paste dialog's Don't care option
|
||
* is the path that leaves normalized coordinates unchanged.
|
||
*/
|
||
export function remapImportedRegions(boxes, opts = {}) {
|
||
const list = boxes || [];
|
||
const source = opts.source || "stretch";
|
||
if (source === "stretch") return list;
|
||
const destW = opts.destW;
|
||
const destH = opts.destH;
|
||
if (!(destW > 0) || !(destH > 0)) return list;
|
||
let srcW = destW;
|
||
let srcH = destH;
|
||
if (source === "dimensions") {
|
||
srcW = Number(opts.srcW);
|
||
srcH = Number(opts.srcH);
|
||
if (!(srcW > 0)) srcW = 1024;
|
||
if (!(srcH > 0)) srcH = 1024;
|
||
} else if (source === "aspect") {
|
||
const dims = dimsFromAspect(opts.aspect || "1:1");
|
||
if (!dims) return list;
|
||
srcW = dims.w;
|
||
srcH = dims.h;
|
||
} else if (source !== "same") {
|
||
return list;
|
||
}
|
||
if (srcW === destW && srcH === destH) return list;
|
||
const mode = Object.prototype.hasOwnProperty.call(SCALE_ANCHORS, opts.mode) ? opts.mode : "center";
|
||
return transformBoxesContentUniform(list, srcW, srcH, destW, destH, mode);
|
||
}
|