Files
DemonGatanjieuandClaude Opus 5.5 8c044d0d5a Keep very large pictures from freezing the gallery
A 20480x13312 upscale is past Pillow's pixel limit, so its card image
failed and the catch-all fallback served the 324 MB original as the
thumbnail: the gallery and the AI workbench decoded it every time.

- A picture past the limit is shrunk once by a child process
  (huge_picture.py, low priority, one at a time) and cached; a card is
  never a large original, one with no card image gets an SVG with its size.
- /anomalous/output_preview gives the viewers the picture or a cached
  4096 px copy of a very large one; the viewer (moved to
  ui_gallery_viewer.js), the image workbench's stage and the AI pages'
  larger pictures use it, with a note and "Open the original".
- The listing carries each file's size (and big ones' pixel size); such a
  card says "Very large".
- The browser-side PNG metadata read stops at the image data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:23:22 +08:00

407 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Marking a picture for the AI (api/ai_marks.py): a window over the page with the picture and four
* simple tools - box, circle, arrow, pen - in three colours, each mark numbered with a note of what
* to change there, plus a line for the whole picture, and a brush that paints the area to repaint;
* and how to change it (MODES, the AI reads it first as the user's choice): leave it to the AI,
* change only the marked areas (painting with the brush picks it), or make the whole picture anew.
* "Send to the AI" draws the marks onto the picture (at most MAX_EDGE px), and sends that PNG with
* the marks (fractions of the picture) and the choice to the task; for "only the marked areas",
* also a mask (white where boxes, circles, lines and brush strokes are) that the server turns into
* the picture ready to repaint. The AI gets the notes and the marked picture with its next step.
* Also the timeline's row for marks sent (marksStep), which says the choice. Every listener lives
* as long as the window and goes with it.
*/
import { translate as t } from './locales.js';
import { anomalousAlert } from './ui_dialog.js';
import { pictureShown } from './ai_task_view.js';
import { deliveryState } from './ui_ai_delivery.js';
const TOOLS = [['box', '□'], ['circle', '○'], ['arrow', '↗'], ['pen', '✎'], ['mask', '▧']];
const MASK_COLOR = 'rgba(255, 64, 160, 0.45)';
const MAX_STROKES = 300;
const MASK_EDGE = 4096;
const AREAS = new Set(['box', 'circle', 'pen', 'mask']);
const MODES = ['auto', 'local', 'whole'];
const COLORS = ['#ff4d4f', '#fadb14', '#36cfc9'];
const MAX_EDGE = 2048;
const MAX_MARKS = 20;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick, title) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
if (title) {
node.title = title;
node.setAttribute('aria-label', title);
}
return node;
}
/** The box around a mark's points (fractions), the form the server keeps. */
function bounds(mark) {
const xs = mark.points.map(point => point[0]);
const ys = mark.points.map(point => point[1]);
const x = Math.min(...xs);
const y = Math.min(...ys);
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
}
/** A brush stroke of the repaint area on a 2D context of `w` × `h` px, in `color`. */
function stroke(ctx, mark, w, h, color = MASK_COLOR) {
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(2, mark.size * Math.min(w, h));
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
const points = mark.points.length > 1 ? mark.points : [mark.points[0], mark.points[0]];
points.forEach(([x, y], index) => (index ? ctx.lineTo(x * w, y * h) : ctx.moveTo(x * w, y * h)));
ctx.stroke();
}
/** The repaint mask of `marks` on `w` × `h` px: white where boxes, circles, lines and strokes are. */
function maskOf(marks, w, h) {
const out = document.createElement('canvas');
out.width = w;
out.height = h;
const ctx = out.getContext('2d');
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = '#fff';
for (const mark of marks) {
const [start, end] = [mark.points[0], mark.points.at(-1)];
if (mark.shape === 'mask') {
stroke(ctx, mark, w, h, '#fff');
} else if (mark.shape === 'pen') {
stroke(ctx, { ...mark, size: 0.03 }, w, h, '#fff');
} else if (mark.shape === 'box') {
ctx.fillRect(Math.min(start[0], end[0]) * w, Math.min(start[1], end[1]) * h, Math.abs(end[0] - start[0]) * w, Math.abs(end[1] - start[1]) * h);
} else if (mark.shape === 'circle') {
ctx.beginPath();
ctx.ellipse((start[0] + end[0]) / 2 * w, (start[1] + end[1]) / 2 * h, Math.abs(end[0] - start[0]) / 2 * w, Math.abs(end[1] - start[1]) / 2 * h, 0, 0, Math.PI * 2);
ctx.fill();
}
}
return out.toDataURL('image/png');
}
/** One mark on a 2D context of `w` × `h` px, with its number (a brush stroke has none). */
function paint(ctx, mark, number, w, h) {
if (mark.shape === 'mask') {
stroke(ctx, mark, w, h);
return;
}
const line = Math.max(2, Math.round(Math.min(w, h) * 0.006));
const [start, end] = [mark.points[0], mark.points.at(-1)];
ctx.strokeStyle = mark.color;
ctx.fillStyle = mark.color;
ctx.lineWidth = line;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
if (mark.shape === 'box') {
ctx.rect(start[0] * w, start[1] * h, (end[0] - start[0]) * w, (end[1] - start[1]) * h);
} else if (mark.shape === 'circle') {
ctx.ellipse((start[0] + end[0]) / 2 * w, (start[1] + end[1]) / 2 * h,
Math.abs(end[0] - start[0]) / 2 * w, Math.abs(end[1] - start[1]) / 2 * h, 0, 0, Math.PI * 2);
} else if (mark.shape === 'arrow') {
const [x0, y0, x1, y1] = [start[0] * w, start[1] * h, end[0] * w, end[1] * h];
const angle = Math.atan2(y1 - y0, x1 - x0);
const head = line * 5;
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.moveTo(x1, y1);
ctx.lineTo(x1 - head * Math.cos(angle - 0.45), y1 - head * Math.sin(angle - 0.45));
ctx.moveTo(x1, y1);
ctx.lineTo(x1 - head * Math.cos(angle + 0.45), y1 - head * Math.sin(angle + 0.45));
} else {
mark.points.forEach(([x, y], index) => (index ? ctx.lineTo(x * w, y * h) : ctx.moveTo(x * w, y * h)));
}
ctx.stroke();
// The number, where the mark starts.
const radius = line * 4;
const cx = Math.min(w - radius, Math.max(radius, start[0] * w));
const cy = Math.min(h - radius, Math.max(radius, start[1] * h));
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#111';
ctx.font = `bold ${Math.round(radius * 1.2)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(number), cx, cy + 1);
}
/**
* Opens the marker on output picture `image` ({filename, subfolder}) for task `taskId`;
* `sent()` runs once the marks are with the task.
*/
export function openMarker(image, taskId, sent) {
const marks = [];
let tool = 'box';
let mode = 'auto';
let color = COLORS[0];
let brush = 0.05;
let drawing = null;
const noted = () => marks.filter(mark => mark.shape !== 'mask');
const overlay = el('div', 'anomalous-ai-marker-overlay');
const dialog = el('div', 'anomalous-ai-marker');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
const head = el('div', 'anomalous-ai-marker-head');
head.append(el('div', 'anomalous-ai-marker-title', t('aiMarkTitle')), button('anomalous-ai-connect-close', '×', () => close(), t('close')));
const bar = el('div', 'anomalous-ai-marker-tools');
const toolButtons = TOOLS.map(([id, glyph]) => {
const pick = button(`anomalous-ai-marker-tool${id === tool ? ' is-on' : ''}${id === 'mask' ? ' is-mask' : ''}`, `${glyph} ${t(`aiMarkTool_${id}`)}`, () => {
tool = id;
toolButtons.forEach(item => item.classList.toggle('is-on', item === pick));
size.hidden = id !== 'mask';
});
return pick;
});
const colorButtons = COLORS.map((value) => {
const swatch = button(`anomalous-ai-marker-color${value === color ? ' is-on' : ''}`, '', () => {
color = value;
colorButtons.forEach(item => item.classList.toggle('is-on', item === swatch));
}, t('aiMarkColor'));
swatch.style.background = value;
return swatch;
});
const size = el('label', 'anomalous-ai-marker-size');
const sizeInput = el('input');
sizeInput.type = 'range';
sizeInput.min = '1';
sizeInput.max = '15';
sizeInput.value = String(brush * 100);
sizeInput.addEventListener('input', () => { brush = Number(sizeInput.value) / 100; });
size.append(el('span', '', t('aiMarkBrush')), sizeInput);
size.hidden = true;
bar.append(...toolButtons, size, el('span', 'anomalous-ai-marker-gap'), ...colorButtons, el('span', 'anomalous-ai-marker-gap'),
button('anomalous-ai-marker-tool', `↶ ${t('aiMarkUndo')}`, () => { marks.pop(); redraw(); }),
button('anomalous-ai-marker-tool', t('aiMarkClear'), () => { marks.length = 0; redraw(); }));
const stage = el('div', 'anomalous-ai-marker-stage');
const picture = el('img', 'anomalous-ai-marker-picture');
picture.alt = image.filename;
picture.src = pictureShown({ ...image, folder: 'output' });
const canvas = el('canvas', 'anomalous-ai-marker-canvas');
stage.append(picture, canvas);
const side = el('div', 'anomalous-ai-marker-side');
const list = el('ol', 'anomalous-ai-marker-list');
const empty = el('div', 'anomalous-ai-marker-empty', t('aiMarkEmpty'));
const whole = el('textarea', 'anomalous-ai-marker-whole');
whole.placeholder = t('aiMarkWhole');
whole.rows = 3;
const send = button('anomalous-ai-marker-send', t('aiMarkSend'), () => void submit());
// How to change it: one of three, each saying what it means.
const ways = el('div', 'anomalous-ai-marker-modes');
ways.setAttribute('role', 'radiogroup');
ways.setAttribute('aria-label', t('aiMarkModeTitle'));
const wayButtons = MODES.map((id) => {
const way = el('button', 'anomalous-ai-marker-mode');
way.type = 'button';
way.dataset.mode = id;
way.setAttribute('role', 'radio');
const words = el('span', 'anomalous-ai-marker-mode-words');
words.append(el('span', 'anomalous-ai-marker-mode-name', t(`aiMarkMode_${id}`)), el('span', 'anomalous-ai-marker-mode-why'));
way.append(el('span', 'anomalous-ai-marker-mode-dot'), words);
way.onclick = () => {
mode = id;
// Only the marked areas, and nothing marked as an area yet: the brush to paint one.
if (id === 'local' && !marks.some(mark => AREAS.has(mark.shape))) toolButtons[TOOLS.findIndex(([name]) => name === 'mask')].click();
showModes();
};
return way;
});
ways.append(el('div', 'anomalous-ai-marker-modes-title', t('aiMarkModeTitle')), ...wayButtons);
function showModes() {
const areas = marks.some(mark => AREAS.has(mark.shape));
for (const way of wayButtons) {
const id = way.dataset.mode;
way.classList.toggle('is-on', id === mode);
way.setAttribute('aria-checked', String(id === mode));
way.querySelector('.anomalous-ai-marker-mode-why').textContent = t(id === 'local' && !areas ? 'aiMarkModeNeedArea' : `aiMarkModeWhy_${id}`);
}
}
side.append(empty, list, whole, ways, send, el('div', 'anomalous-ai-marker-note', t('aiMarkNote')));
const body = el('div', 'anomalous-ai-marker-body');
const left = el('div', 'anomalous-ai-marker-left');
left.append(bar, stage);
body.append(left, side);
dialog.append(head, body);
overlay.append(dialog);
const point = (event) => {
const rect = canvas.getBoundingClientRect();
return [Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)), Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height))];
};
function redraw() {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
let number = 0;
[...marks, ...(drawing ? [drawing] : [])].forEach(mark => paint(ctx, mark, mark.shape === 'mask' ? 0 : ++number, canvas.width, canvas.height));
empty.hidden = marks.length > 0;
showModes();
// The notes, one per mark (not per brush stroke), kept as typed.
const notes = noted();
while (list.children.length > notes.length) list.lastElementChild.remove();
notes.forEach((mark, index) => {
let row = list.children[index];
if (!row) {
row = el('li', 'anomalous-ai-marker-row');
const note = el('input', 'anomalous-ai-marker-input');
note.placeholder = t('aiMarkNotePlaceholder');
note.addEventListener('input', () => { noted()[[...list.children].indexOf(row)].note = note.value; });
row.append(el('span', 'anomalous-ai-marker-badge'), el('span', 'anomalous-ai-marker-shape'), note,
button('anomalous-ai-marker-remove', '×', () => { marks.splice(marks.indexOf(noted()[[...list.children].indexOf(row)]), 1); redraw(); }, t('aiMarkRemove')));
list.append(row);
requestAnimationFrame(() => note.focus());
}
const badge = row.querySelector('.anomalous-ai-marker-badge');
badge.textContent = String(index + 1);
badge.style.background = mark.color;
row.querySelector('.anomalous-ai-marker-shape').textContent = t(`aiMarkTool_${mark.shape}`);
row.querySelector('input').value = mark.note || '';
});
}
const fit = () => {
canvas.width = picture.clientWidth;
canvas.height = picture.clientHeight;
redraw();
};
picture.addEventListener('load', fit);
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(fit) : null;
observer?.observe(picture);
canvas.addEventListener('pointerdown', (event) => {
if (tool === 'mask' ? marks.length - noted().length >= MAX_STROKES : noted().length >= MAX_MARKS) return;
canvas.setPointerCapture(event.pointerId);
drawing = tool === 'mask' ? { shape: 'mask', size: brush, points: [point(event)] } : { shape: tool, color, points: [point(event)], note: '' };
if (tool === 'mask') redraw();
});
canvas.addEventListener('pointermove', (event) => {
if (!drawing) return;
if (drawing.shape === 'pen' || drawing.shape === 'mask') drawing.points.push(point(event));
else drawing.points[1] = point(event);
redraw();
});
const finish = () => {
if (!drawing) return;
const box = bounds(drawing);
// A click without a drag makes no mark; a brush click is a dot of the repaint area.
if (drawing.shape === 'mask') {
marks.push(drawing);
mode = 'local';
} else if (drawing.points.length > 1 && (box.w > 0.01 || box.h > 0.01)) {
marks.push(drawing);
}
drawing = null;
redraw();
};
canvas.addEventListener('pointerup', finish);
canvas.addEventListener('pointercancel', finish);
const onKey = (event) => {
if (event.key === 'Escape') {
event.stopPropagation();
close();
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z' && event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA') {
event.preventDefault();
event.stopPropagation();
marks.pop();
redraw();
}
};
window.addEventListener('keydown', onKey, true);
function close() {
observer?.disconnect();
window.removeEventListener('keydown', onKey, true);
overlay.remove();
}
/** The picture with the marks drawn on it, as a PNG data URL. */
function composite() {
const scale = Math.min(1, MAX_EDGE / Math.max(picture.naturalWidth, picture.naturalHeight));
const out = document.createElement('canvas');
out.width = Math.round(picture.naturalWidth * scale);
out.height = Math.round(picture.naturalHeight * scale);
const ctx = out.getContext('2d');
ctx.drawImage(picture, 0, 0, out.width, out.height);
let number = 0;
marks.forEach(mark => paint(ctx, mark, mark.shape === 'mask' ? 0 : ++number, out.width, out.height));
return out.toDataURL('image/png');
}
/** The repaint mask at the picture's own size (at most MASK_EDGE px; the server fits it to the picture). */
function repaintMask() {
const scale = Math.min(1, MASK_EDGE / Math.max(picture.naturalWidth, picture.naturalHeight));
return maskOf(marks, Math.round(picture.naturalWidth * scale), Math.round(picture.naturalHeight * scale));
}
async function submit() {
const areas = marks.some(mark => AREAS.has(mark.shape));
if (!marks.length || (mode === 'local' ? !areas : !noted().length)) {
await anomalousAlert(t(marks.length && mode === 'local' ? 'aiMarkModeNeedArea' : 'aiMarkNeedOne'));
return;
}
send.disabled = true;
try {
const response = await fetch(`/anomalous/ai_tasks/${encodeURIComponent(taskId)}/marks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image: [image.subfolder, image.filename].filter(Boolean).join('/'), png: composite(), text: whole.value.trim(),
marks: noted().map(mark => ({ shape: mark.shape, note: (mark.note || '').trim(), ...bounds(mark) })), mode,
...(mode === 'local' ? { mask: repaintMask() } : {}),
}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`);
} catch (error) {
send.disabled = false;
await anomalousAlert(error.message);
return;
}
close();
sent?.();
}
overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); });
document.body.append(overlay);
}
/** The timeline's row body for marks the user sent: the marked picture, how to change it, the notes, whether the AI has them. */
export function marksStep(step, taskId, owner) {
const box = el('div', 'anomalous-ai-marks-sent');
const url = `/anomalous/ai_tasks/${encodeURIComponent(taskId)}/marks/${encodeURIComponent(step.marked)}`;
const open = button('anomalous-ai-marks-sent-picture', '', () => owner?.showGalleryViewer?.(url, false), t('aiOpenPicture'));
const picture = el('img');
picture.src = url;
picture.alt = step.image?.filename || '';
picture.loading = 'lazy';
open.append(picture);
const notes = el('ol', 'anomalous-ai-marks-sent-notes');
for (const mark of step.marks || []) notes.append(el('li', '', mark.note || t('aiMarkNoNote')));
box.append(open, el('div', 'anomalous-ai-marks-sent-words'));
const words = box.lastChild;
if (step.note) words.append(el('div', 'anomalous-ai-user-text', step.note));
const mode = step.mode || (step.inpaint ? 'local' : ''); // marks sent before there was a choice: none, or a mask
if (mode) words.append(el('div', `anomalous-ai-marks-mode is-${mode}`, t('aiMarkModeChosen', { mode: t(`aiMarkMode_${mode}`) })));
if (step.inpaint) words.append(el('div', 'anomalous-ai-marks-inpaint', t('aiMarkInpaintReady', { file: step.inpaint })));
words.append(notes, deliveryState(step));
return box;
}