From 4fa6dcae41997dc3e6149fe96bf764f4a9c22f30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 22:07:14 +0000 Subject: [PATCH 1/9] ritmo-tools: share finding a game's files between the review tools The music review walked folders, named files by their path, and found the game's root on its own. The pixel review needs the same, so the three move to src/internal/files.ts and music uses them from there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HendNec6exyZNa2zBVD977 --- packages/tools/src/internal/files.ts | 51 +++++++++++++++++++++++++ packages/tools/src/music/cli/library.ts | 45 ++++------------------ 2 files changed, 58 insertions(+), 38 deletions(-) create mode 100644 packages/tools/src/internal/files.ts diff --git a/packages/tools/src/internal/files.ts b/packages/tools/src/internal/files.ts new file mode 100644 index 00000000..ef1fd905 --- /dev/null +++ b/packages/tools/src/internal/files.ts @@ -0,0 +1,51 @@ +/** + * Finding a game's files for a review page, the same way for every tool: + * folders walked by name (hidden folders and `node_modules` never), ids as + * paths from the working directory, and the game's root, where a review + * that must not ship with the game is kept. + */ +import { existsSync } from "node:fs" +import { readdir } from "node:fs/promises" +import { basename, dirname, join, relative, sep } from "node:path" + +/** A path with `/` between folders, whatever the platform: how review files and the page name files. */ +export const posix = (path: string): string => path.split(sep).join("/") + +/** How a page names a file: its path from the working directory, with `/` between folders. */ +export const idOf = (file: string): string => posix(relative(process.cwd(), file)) || basename(file) + +/** + * Every file under `folder`, in no particular order. Hidden folders and + * `node_modules` are passed over, and so is any folder `skip` names (a tool's + * own outputs, such as a song's stems). + */ +export const walk = async (folder: string, skip: (name: string) => boolean = () => false): Promise> => { + const found: Array = [] + for (const entry of await readdir(folder, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!entry.name.startsWith(".") && entry.name !== "node_modules" && !skip(entry.name)) found.push(...(await walk(join(folder, entry.name), skip))) + } else if (entry.isFile()) found.push(join(folder, entry.name)) + } + return found +} + +const roots = new Map() + +/** The game's root for a file: the nearest folder at or above it with a `package.json`, else the file's own folder. */ +export const rootOf = (file: string): string => { + const start = dirname(file) + const known = roots.get(start) + if (known !== undefined) return known + let folder = start + for (;;) { + if (existsSync(join(folder, "package.json"))) break + const parent = dirname(folder) + if (parent === folder) { + folder = start + break + } + folder = parent + } + roots.set(start, folder) + return folder +} diff --git a/packages/tools/src/music/cli/library.ts b/packages/tools/src/music/cli/library.ts index 1a953922..721117e1 100644 --- a/packages/tools/src/music/cli/library.ts +++ b/packages/tools/src/music/cli/library.ts @@ -9,8 +9,9 @@ * around), so it is left out of a folder. */ import { existsSync, statSync } from "node:fs" -import { readdir, readFile, writeFile } from "node:fs/promises" -import { basename, dirname, extname, join, relative, resolve, sep } from "node:path" +import { readFile, writeFile } from "node:fs/promises" +import { basename, dirname, extname, join, relative, resolve } from "node:path" +import { idOf, posix, rootOf, walk } from "../../internal/files.ts" import { hashBytes } from "../../internal/hash.ts" import { mtime } from "../../internal/serve.ts" import { audioChangedOf, AUDIO_REVIEWS, changedOf, decodeAudioReviews, decodeReview, emptyAudioReview, emptyReview, encodeAudioReviews, reviewPath, type AudioReview, type Review, type Status } from "../core/review.ts" @@ -27,21 +28,10 @@ export interface MusicFile { readonly file: string } -const posix = (path: string): string => path.split(sep).join("/") +const fileOf = (file: string): MusicFile => ({ id: idOf(file), kind: AUDIO_FILES.includes(extname(file).toLowerCase()) ? "audio" : "song", file }) -const fileOf = (file: string): MusicFile => ({ id: posix(relative(process.cwd(), file)) || basename(file), kind: AUDIO_FILES.includes(extname(file).toLowerCase()) ? "audio" : "song", file }) - -const skipFolder = (name: string): boolean => name.startsWith(".") || name === "node_modules" || name.endsWith(".stems") - -const walk = async (folder: string): Promise> => { - const found: Array = [] - for (const entry of await readdir(folder, { withFileTypes: true })) { - if (entry.isDirectory()) { - if (!skipFolder(entry.name)) found.push(...(await walk(join(folder, entry.name)))) - } else if (entry.isFile()) found.push(join(folder, entry.name)) - } - return found -} +/** A song's stems are the song again. */ +const isStems = (name: string): boolean => name.endsWith(".stems") /** Whether a file found in a folder is music to review, among the folder's other files. */ const isMusic = (file: string, all: ReadonlySet): boolean => { @@ -58,7 +48,7 @@ export const findMusic = async (paths: ReadonlyArray): Promise): Promise() - -/** The game's root for a file: the nearest folder at or above it with a `package.json`, else the file's own folder. */ -const rootOf = (file: string): string => { - const start = dirname(file) - const known = roots.get(start) - if (known !== undefined) return known - let folder = start - for (;;) { - if (existsSync(join(folder, "package.json"))) break - const parent = dirname(folder) - if (parent === folder) { - folder = start - break - } - folder = parent - } - roots.set(start, folder) - return folder -} - /** Where a plain audio file's review is kept: the shared file, and the file's key in it. */ export const audioReviewPlace = (file: string): { readonly reviews: string; readonly key: string } => { const root = rootOf(file) From d6496f3b61d0dd2d56d66aa603e44401281d2a2c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 22:13:37 +0000 Subject: [PATCH 2/9] ritmo-tools pixel: what a review of pixel art keeps, and reading a sheet back from its JSON A review holds a status, a note, and comments pinned to an area of one frame. Approving keeps a hash of each frame's decoded pixels and its duration, so a re-export that looks the same is no change while new pixels or new timing are named, frame by frame. A transparent pixel hashes the same whatever color it hides, since nobody can see it. Sheets are read back from the JSON pixel sheet writes, or from Aseprite's, so the page can play each animation with its durations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HendNec6exyZNa2zBVD977 --- packages/tools/src/pixel/core/review.ts | 355 +++++++++++++++++++ packages/tools/src/pixel/core/spritesheet.ts | 90 +++++ 2 files changed, 445 insertions(+) create mode 100644 packages/tools/src/pixel/core/review.ts diff --git a/packages/tools/src/pixel/core/review.ts b/packages/tools/src/pixel/core/review.ts new file mode 100644 index 00000000..31b24fde --- /dev/null +++ b/packages/tools/src/pixel/core/review.ts @@ -0,0 +1,355 @@ +/** + * The pixel art review: what the person said about each of a game's assets + * on the review page (`ritmo-tools pixel review`), for the agent to read + * back (`pixel notes`). One file, `pixel.review.json`, at the game's root + * (beside the nearest `package.json`), keyed by each asset's path from + * there: kept beside the art it would ship with the game from `public/`. + * + * Each asset has a status (new, approved, changes asked), a note on the + * whole, and comments pinned to an area of one frame ("these pixels, + * here"). Once the agent has acted on a comment it is resolved, with a reply + * (`pixel resolve`); the person can reopen it. + * + * Once approved, the review keeps what the person saw: a hash of each + * frame's pixels and how long each shows. The hash is of the decoded pixels, + * not the file's bytes, so a re-export that looks the same is no change, and + * a transparent pixel hashes the same whatever color it hides. + */ +import { makeHash } from "../../internal/hash.ts" +import type { RgbaImage } from "./grid.ts" +import type { Decoded } from "./recipe.ts" + +export type Status = "new" | "approved" | "changes" +export const statuses: ReadonlyArray = ["new", "approved", "changes"] + +/** The file every asset's review lives in, at the game's root. */ +export const REVIEW_FILE = "pixel.review.json" + +/** + * A comment pinned to an area of one frame. `x` and `y` are its top-left + * pixel, in the frame's own pixels; `width` and `height` its size (one pixel + * when unset). Frames count from 0 within their animation; a still has one + * frame, 0, and no animation. + */ +export interface Comment { + readonly animation?: string | undefined + readonly frame: number + readonly x: number + readonly y: number + readonly width?: number | undefined + readonly height?: number | undefined + readonly text: string + readonly resolved?: boolean | undefined + readonly reply?: string | undefined +} + +/** What the person saw when they approved an asset: a hash of each frame's pixels, in the sheet's order, and how long each frame shows (a still has none). */ +export interface Approved { + readonly frames: ReadonlyArray + readonly durations?: ReadonlyArray | undefined +} + +export interface AssetReview { + readonly status: Status + readonly note: string + readonly comments: ReadonlyArray + /** Set when the person approves. */ + readonly approved?: Approved | undefined +} + +export interface Review { + readonly assets: Readonly> +} + +export const emptyReview: Review = { assets: {} } +export const newAsset: AssetReview = { status: "new", note: "", comments: [] } + +// --------------------------------------------------------------------------- +// What an asset looks like now + +/** + * One frame of an asset as it is now: where it is on the picture, how long + * it shows, and a hash of its pixels. A still is one frame, the whole + * picture, named "" with no animation and no duration. + */ +export interface Frame { + /** Its name in the sheet's JSON ("walk 0"); "" for a still. */ + readonly name: string + readonly animation?: string | undefined + /** Its place in its animation, from 0. */ + readonly index: number + readonly x: number + readonly y: number + readonly w: number + readonly h: number + /** How long it shows, in milliseconds; none for a still. */ + readonly duration?: number | undefined + readonly hash: string +} + +/** The hash of a frame's size and each pixel's color: a transparent pixel is the same whatever its hidden color. */ +export const hashFrame = (image: RgbaImage, x: number, y: number, w: number, h: number): string => { + const hash = makeHash() + for (const v of [w, w >> 8, h, h >> 8]) hash.mix(v) + for (let row = y; row < y + h; row++) + for (let column = x; column < x + w; column++) { + const i = (row * image.width + column) * 4 + const alpha = image.data[i + 3] ?? 0 + // A transparent pixel is one byte, any other alpha starts four: the two never read alike. + hash.mix(alpha) + if (alpha === 0) continue + hash.mix(image.data[i]!) + hash.mix(image.data[i + 1]!) + hash.mix(image.data[i + 2]!) + } + return hash.done() +} + +/** What to keep when the person approves an asset, as it looks now. */ +export const approvalOf = (frames: ReadonlyArray): Approved => { + const durations = frames.map((frame) => frame.duration) + return { frames: frames.map((frame) => frame.hash), ...(durations.every((d) => d !== undefined) && durations.length > 0 ? { durations } : {}) } +} + +/** "3-5, 7": frame numbers as runs. */ +const spans = (numbers: ReadonlyArray): string => { + const parts: Array = [] + let start: number | undefined + let last: number | undefined + for (const n of [...numbers, undefined]) { + if (n !== undefined && last !== undefined && n === last + 1) { + last = n + continue + } + if (start !== undefined) parts.push(start === last ? `${start}` : `${start}-${last}`) + start = n + last = n + } + return parts.join(", ") +} + +/** "walk frames 3-5 and idle frame 0": some frames, by animation. */ +const framesNamed = (frames: ReadonlyArray): string => { + const by = new Map>() + for (const frame of frames) by.set(frame.animation ?? "", [...(by.get(frame.animation ?? "") ?? []), frame.index]) + return [...by.entries()].map(([animation, indices]) => `${animation === "" ? "" : `${animation} `}frame${indices.length === 1 ? "" : "s"} ${spans(indices)}`).join(" and ") +} + +/** What is different from what the person approved, in a few words; `undefined` when nothing is. */ +export const changedSince = (approved: Approved, frames: ReadonlyArray): string | undefined => { + if (frames.length !== approved.frames.length) return `${frames.length} frame${frames.length === 1 ? "" : "s"}, ${approved.frames.length} when approved` + const parts: Array = [] + const looks = frames.filter((frame, i) => frame.hash !== approved.frames[i]) + if (looks.length > 0) parts.push(frames.length === 1 && frames[0]!.animation === undefined ? "it looks different" : `${framesNamed(looks)} look${looks.length === 1 ? "s" : ""} different`) + const timed = approved.durations === undefined ? [] : frames.filter((frame, i) => frame.duration !== approved.durations?.[i]) + if (timed.length === 1) parts.push(`${framesNamed(timed)} shows for ${timed[0]!.duration ?? 0} ms, ${approved.durations?.[frames.indexOf(timed[0]!)]} when approved`) + else if (timed.length > 1) parts.push(`the timing of ${framesNamed(timed)} changed`) + return parts.length === 0 ? undefined : parts.join("; ") +} + +/** What changed since the person approved this asset, when they did and something has. A review with no hashes has nothing to compare. */ +export const changedOf = (review: AssetReview, frames: ReadonlyArray): string | undefined => + review.status === "approved" && review.approved !== undefined ? changedSince(review.approved, frames) : undefined + +// --------------------------------------------------------------------------- +// The file + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) +const whole = (value: unknown, least = 0): value is number => typeof value === "number" && Number.isInteger(value) && value >= least + +const commentOf = (raw: unknown, where: string, errors: Array): Comment | undefined => { + if (!isRecord(raw) || !whole(raw.frame) || !whole(raw.x) || !whole(raw.y) || typeof raw.text !== "string") { + errors.push(`${where} must be {"frame", "x", "y", "text"}, whole numbers from 0`) + return undefined + } + if ((raw.width !== undefined && !whole(raw.width, 1)) || (raw.height !== undefined && !whole(raw.height, 1))) { + errors.push(`${where}.width and .height are whole numbers from 1`) + return undefined + } + if (raw.animation !== undefined && typeof raw.animation !== "string") { + errors.push(`${where}.animation must be a name`) + return undefined + } + return commentJson({ + animation: raw.animation, + frame: raw.frame, + x: raw.x, + y: raw.y, + width: raw.width, + height: raw.height, + text: raw.text, + resolved: raw.resolved === true, + reply: typeof raw.reply === "string" ? raw.reply : undefined + }) +} + +const approvedOf = (raw: unknown): Approved | undefined => { + if (!isRecord(raw) || !Array.isArray(raw.frames) || !raw.frames.every((hash) => typeof hash === "string")) return undefined + const frames: ReadonlyArray = raw.frames + if (raw.durations === undefined) return { frames } + if (!Array.isArray(raw.durations) || raw.durations.length !== frames.length || !raw.durations.every((d) => typeof d === "number")) return undefined + const durations: ReadonlyArray = raw.durations + return { frames, durations } +} + +const assetOf = (raw: unknown, where: string, errors: Array): AssetReview | undefined => { + if (!isRecord(raw)) { + errors.push(`${where} must be {"status", "note", "comments"}`) + return undefined + } + const status = statuses.find((entry) => entry === raw.status) + if (raw.status !== undefined && status === undefined) errors.push(`${where}.status must be one of ${statuses.join(", ")}`) + if (raw.comments !== undefined && !Array.isArray(raw.comments)) errors.push(`${where}.comments must be a list`) + const comments = (Array.isArray(raw.comments) ? raw.comments : []).flatMap((comment, i) => commentOf(comment, `${where}.comments[${i}]`, errors) ?? []) + const approved = approvedOf(raw.approved) + if (raw.approved !== undefined && approved === undefined) errors.push(`${where}.approved must be {"frames": ["hash", ...], "durations": [ms, ...]}`) + return { status: status ?? "new", note: typeof raw.note === "string" ? raw.note : "", comments, ...(approved === undefined ? {} : { approved }) } +} + +/** `pixel.review.json`, or the page's whole review: each asset's review by its path. */ +export const decodeReview = (text: string): Decoded => { + let raw: unknown + try { + raw = JSON.parse(text) + } catch (error) { + return { ok: false, error: `not JSON: ${error instanceof Error ? error.message : String(error)}` } + } + if (!isRecord(raw) || !isRecord(raw.assets)) return { ok: false, error: 'a review is {"version": 1, "assets": {"path": {"status": ..., "note": ..., "comments": [...]}}}' } + const errors: Array = [] + const assets: Record = {} + for (const [path, value] of Object.entries(raw.assets)) { + const review = assetOf(value, path, errors) + if (review !== undefined) assets[path] = review + } + return errors.length > 0 ? { ok: false, error: errors.join("; ") } : { ok: true, value: { assets } } +} + +/** Whether a review says nothing: not reviewed, no note, no comments. */ +export const isEmpty = (review: AssetReview): boolean => review.status === "new" && review.note === "" && review.comments.length === 0 && review.approved === undefined + +/** A comment with its fields in one order, as the file writes it. */ +const commentJson = (comment: Comment): Comment => ({ + ...(comment.animation === undefined ? {} : { animation: comment.animation }), + frame: comment.frame, + x: comment.x, + y: comment.y, + ...(comment.width === undefined || comment.width === 1 ? {} : { width: comment.width }), + ...(comment.height === undefined || comment.height === 1 ? {} : { height: comment.height }), + text: comment.text, + ...(comment.resolved === true ? { resolved: true } : {}), + ...(comment.reply === undefined || comment.reply === "" ? {} : { reply: comment.reply }) +}) + +/** An asset's review as the file writes it: what is unset left out. */ +const assetJson = (entry: AssetReview) => ({ + status: entry.status, + ...(entry.note === "" ? {} : { note: entry.note }), + ...(entry.comments.length === 0 ? {} : { comments: entry.comments.map(commentJson) }), + ...(entry.approved === undefined ? {} : { approved: entry.approved.durations === undefined ? { frames: entry.approved.frames } : { frames: entry.approved.frames, durations: entry.approved.durations } }) +}) + +/** The file as written: assets in path order, and an asset nothing was said about left out. */ +export const encodeReview = (review: Review): string => { + const assets: Record = {} + for (const path of Object.keys(review.assets).sort()) { + const entry = review.assets[path]! + if (!isEmpty(entry)) assets[path] = assetJson(entry) + } + return JSON.stringify({ version: 1, assets }, null, 2) + "\n" +} + +/** Marks comments (by number, from 1) as resolved with the agent's reply; fails on a number the asset has no comment for. */ +export const resolveComments = (review: Review, asset: string, numbers: ReadonlyArray, reply: string): Decoded => { + const said = review.assets[asset] ?? newAsset + const missing = numbers.filter((n) => !Number.isInteger(n) || n < 1 || n > said.comments.length) + if (missing.length > 0) return { ok: false, error: `${asset} has no comment ${missing.join(", ")} (it has ${said.comments.length})` } + const comments = said.comments.map((comment, index) => (numbers.includes(index + 1) ? { ...comment, resolved: true, ...(reply === "" ? {} : { reply }) } : comment)) + return { ok: true, value: { assets: { ...review.assets, [asset]: { ...said, comments } } } } +} + +const same = (a: AssetReview, b: AssetReview): boolean => JSON.stringify(assetJson(a)) === JSON.stringify(assetJson(b)) + +/** A comment without what the agent sets on it: the same comment, resolved or not. */ +const said = (comment: Comment): string => JSON.stringify(commentJson({ ...comment, resolved: undefined, reply: undefined })) + +/** + * Two reviews written apart from one they both started from (`base`): the + * page's (`mine`) and the file's now (`theirs`, which the agent may have + * written with `pixel resolve`). An asset only one side changed takes that + * side; an asset both changed keeps the page's, with any comment the agent + * resolved meanwhile resolved. + */ +export const mergeReviews = (base: Review, mine: Review, theirs: Review): Review => { + const assets: Record = {} + for (const path of new Set([...Object.keys(base.assets), ...Object.keys(mine.assets), ...Object.keys(theirs.assets)])) { + const was = base.assets[path] ?? newAsset + const ours = mine.assets[path] ?? newAsset + const other = theirs.assets[path] ?? newAsset + const merged = same(ours, was) + ? other + : same(other, was) + ? ours + : { + ...ours, + comments: ours.comments.map((comment) => { + if (comment.resolved === true || !was.comments.some((entry) => entry.resolved !== true && said(entry) === said(comment))) return comment + return other.comments.find((entry) => entry.resolved === true && said(entry) === said(comment)) ?? comment + }) + } + if (!isEmpty(merged)) assets[path] = merged + } + return { assets } +} + +// --------------------------------------------------------------------------- +// What the agent reads + +/** Where a comment is, in words: "walk frame 2, pixels 3,4 to 6,7". */ +export const whereOf = (comment: Comment): string => { + const width = comment.width ?? 1 + const height = comment.height ?? 1 + const pixels = width === 1 && height === 1 ? `pixel ${comment.x},${comment.y}` : `pixels ${comment.x},${comment.y} to ${comment.x + width - 1},${comment.y + height - 1}` + return comment.animation === undefined ? (comment.frame === 0 ? pixels : `frame ${comment.frame}, ${pixels}`) : `${comment.animation} frame ${comment.frame}, ${pixels}` +} + +/** What `pixel notes` says about one asset's review: its status, note, open comments (numbered, as `pixel resolve` takes them), and what changed since it was approved. */ +export const reviewSummary = (review: AssetReview, frames: ReadonlyArray | undefined) => { + const changed = frames === undefined ? undefined : changedOf(review, frames) + // Open comments only, each with its number: resolved ones are done. + const open = review.comments.flatMap((comment, index) => (comment.resolved === true ? [] : [{ number: index + 1, where: whereOf(comment), ...comment }])) + const resolved = review.comments.length - open.length + return { + status: review.status, + note: review.note === "" ? undefined : review.note, + comments: open.length === 0 ? undefined : open, + resolved: resolved === 0 ? undefined : resolved, + changedSinceApproved: changed, + next: + changed !== undefined + ? "It changed since the person approved it. Tell them what changed and ask them to look again; only they approve it." + : review.status === "approved" + ? "The person approved it. Leave it as it is." + : open.length > 0 || review.note !== "" + ? "Act on the note and each comment where it points, then `pixel resolve` the comments with a reply saying what changed, and tell the person." + : review.status === "changes" + ? "The person asked for changes but said nothing yet: ask them what." + : "Nothing said yet. Wait for the person, or ask them to look." + } +} + +interface Summarized { + readonly status: Status + readonly changedSinceApproved?: string | undefined +} + +const urgency = (entry: Summarized): number => (entry.status === "changes" ? 0 : entry.changedSinceApproved !== undefined ? 1 : entry.status === "new" ? 2 : 3) + +/** What is left to do across the assets, with them in the order to act on them: changes asked, changed since approved, not reviewed yet, approved. */ +export const pixelSummary = (entries: ReadonlyArray) => { + const total = (status: Status): number => entries.filter((entry) => entry.status === status).length + const stale = entries.filter((entry) => entry.changedSinceApproved !== undefined).length + return { + summary: `${total("approved")} approved${stale === 0 ? "" : ` (${stale} changed since: ask the person to look again)`}, ${total("changes")} with changes asked, ${total("new")} not reviewed yet (of ${entries.length}).`, + assets: [...entries].sort((a, b) => urgency(a) - urgency(b)) + } +} diff --git a/packages/tools/src/pixel/core/spritesheet.ts b/packages/tools/src/pixel/core/spritesheet.ts index 5980fb32..008189e9 100644 --- a/packages/tools/src/pixel/core/spritesheet.ts +++ b/packages/tools/src/pixel/core/spritesheet.ts @@ -146,3 +146,93 @@ export const spritesheetJson = (info: SpritesheetInfo): string => { ) + "\n" ) } + +// --------------------------------------------------------------------------- +// Reading a sheet's JSON back + +/** One frame of a sheet as its JSON places it: a box on the picture, and how long it shows in milliseconds. */ +export interface SheetRect { + readonly name: string + readonly x: number + readonly y: number + readonly w: number + readonly h: number + readonly duration: number +} + +/** One animation of a sheet read back: its frames (indices into the sheet's), as the JSON lists them and in the order they play. */ +export interface SheetAnimationRead { + readonly name: string + readonly frames: ReadonlyArray + /** `frames` played forward, backward, or there and back. */ + readonly play: ReadonlyArray +} + +/** A sheet as a review shows it: its frames, and its animations. */ +export interface ReadSheet { + readonly frames: ReadonlyArray + readonly animations: ReadonlyArray +} + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) + +/** The frame time Aseprite gives a frame with no duration. */ +const DEFAULT_DURATION = 100 + +const rectOf = (name: string, raw: unknown): SheetRect | undefined => { + if (!isRecord(raw) || !isRecord(raw.frame)) return undefined + const { x, y, w, h } = raw.frame + if (!Number.isInteger(x) || !Number.isInteger(y) || !Number.isInteger(w) || !Number.isInteger(h) || typeof x !== "number" || typeof y !== "number" || typeof w !== "number" || typeof h !== "number") return undefined + return { name, x, y, w, h, duration: typeof raw.duration === "number" && raw.duration > 0 ? raw.duration : DEFAULT_DURATION } +} + +/** + * Reads a spritesheet's JSON (`spritesheetJson`'s, or any "hash" or "array" + * one with frame boxes, as Aseprite and TexturePacker write) for the picture + * it sits beside. `undefined` when it is not a sheet's JSON at all (another + * format, a JSON map): the picture is then a still. A sheet whose frames do + * not fit the picture is an error. + * + * Animations come from Pixi's `animations`, else Aseprite's `frameTags` + * (played the way each says: forward, reverse, or there and back), else all + * the frames in order as one. + */ +export const readSpritesheet = (json: unknown, picture: { readonly width: number; readonly height: number }): { readonly ok: true; readonly sheet: ReadSheet } | { readonly ok: false; readonly error: string } | undefined => { + if (!isRecord(json)) return undefined + const entries: Array = Array.isArray(json.frames) + ? json.frames.map((frame: unknown, i) => [isRecord(frame) && typeof frame.filename === "string" ? frame.filename : String(i), frame] as const) + : isRecord(json.frames) + ? Object.entries(json.frames) + : [] + const frames = entries.map(([name, raw]) => rectOf(name, raw)) + if (frames.length === 0 || frames.some((frame) => frame === undefined)) return undefined + const rects = frames.filter((frame): frame is SheetRect => frame !== undefined) + const outside = rects.find((rect) => rect.w < 1 || rect.h < 1 || rect.x < 0 || rect.y < 0 || rect.x + rect.w > picture.width || rect.y + rect.h > picture.height) + if (outside !== undefined) return { ok: false, error: `its frame "${outside.name}" (${outside.x},${outside.y} ${outside.w}×${outside.h}) is not on the ${picture.width}×${picture.height} picture` } + const index = new Map(rects.map((rect, i) => [rect.name, i])) + const meta = isRecord(json.meta) ? json.meta : {} + const tags = (Array.isArray(meta.frameTags) ? meta.frameTags : []).filter( + (tag): tag is { readonly name: string; readonly from: number; readonly to: number; readonly direction?: unknown } => + isRecord(tag) && typeof tag.name === "string" && Number.isInteger(tag.from) && Number.isInteger(tag.to) + ) + const direction = (name: string): unknown => tags.find((tag) => tag.name === name)?.direction + const ordered = (name: string, frames: Array): SheetAnimationRead => { + const way = direction(name) + if (way === "reverse") return { name, frames, play: [...frames].reverse() } + if (way === "pingpong" && frames.length > 2) return { name, frames, play: [...frames, ...frames.slice(1, -1).reverse()] } + return { name, frames, play: frames } + } + const animations = isRecord(json.animations) + ? Object.entries(json.animations).flatMap(([name, names]) => { + const order = (Array.isArray(names) ? names : []).flatMap((frame) => { + const i = typeof frame === "string" ? index.get(frame) : undefined + return i === undefined ? [] : [i] + }) + return order.length === 0 ? [] : [ordered(name, order)] + }) + : tags.flatMap((tag) => { + const order = Array.from({ length: tag.to - tag.from + 1 }, (_, i) => tag.from + i).filter((i) => i >= 0 && i < rects.length) + return order.length === 0 ? [] : [ordered(tag.name, order)] + }) + return { ok: true, sheet: { frames: rects, animations: animations.length > 0 ? animations : [ordered("frames", rects.map((_, i) => i))] } } +} From 4e56dfc6954fd399e606ccdf9ca52eff7b336ace Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 22:13:37 +0000 Subject: [PATCH 3/9] ritmo-tools pixel: review, notes, and resolve on the command line pixel review serves the art under the named folders on a local page. The server finds, decodes, hashes, and lints every asset (against its nearest pixel.config.json, each frame of a sheet apart), so the page only draws. An id names an asset the server found, never a path it reads. pixel notes reads back what the person said, the changes first, and pixel resolve closes comments by number, the way fx resolve does. The review is one pixel.review.json at the game's root, out of the folder that ships. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HendNec6exyZNa2zBVD977 --- packages/tools/src/internal/serve.ts | 7 +- packages/tools/src/pixel/cli/commands.ts | 137 ++++++- packages/tools/src/pixel/cli/library.ts | 372 ++++++++++++++++++ packages/tools/src/pixel/cli/serve.ts | 79 ++++ packages/tools/test/pixel-review.test.ts | 463 +++++++++++++++++++++++ 5 files changed, 1053 insertions(+), 5 deletions(-) create mode 100644 packages/tools/src/pixel/cli/library.ts create mode 100644 packages/tools/src/pixel/cli/serve.ts create mode 100644 packages/tools/test/pixel-review.test.ts diff --git a/packages/tools/src/internal/serve.ts b/packages/tools/src/internal/serve.ts index 3fbb5d16..9f27ddaf 100644 --- a/packages/tools/src/internal/serve.ts +++ b/packages/tools/src/internal/serve.ts @@ -2,7 +2,8 @@ * The local server under every review page: the page itself, and the tool's * small API beside it under `/api`. The published package serves the page as * built (`dist//page`, made by `prepack`); inside the repository, where - * it is not built, Vite serves it from its source (`src//ui`). + * it is not built, Vite serves it from its source (`src//ui`, or the + * folder the tool names). */ import { existsSync } from "node:fs" import { readFile, stat } from "node:fs/promises" @@ -17,6 +18,8 @@ export type Handler = (request: IncomingMessage, response: ServerResponse) => Pr export interface PageOptions { /** The folder of the tool's `serve.ts` (`import.meta.url`): `../page` is the built page, `../ui` its source. */ readonly from: string + /** The page's source folder, from `from`, when it is not `../ui` (the pixel fixer's page has that one). */ + readonly source?: string | undefined /** Names the Vite plugin, for its errors. */ readonly name: string readonly port: number @@ -127,5 +130,5 @@ const serveSource = async (source: string, options: PageOptions, handler: Handle /** Starts the page; resolves with its address once it listens. */ export const servePage = (options: PageOptions, handler: Handler): Promise
=> { const built = resolve(options.from, "../page") - return existsSync(join(built, "index.html")) ? serveBuilt(built, options, handler) : serveSource(resolve(options.from, "../ui"), options, handler) + return existsSync(join(built, "index.html")) ? serveBuilt(built, options, handler) : serveSource(resolve(options.from, options.source ?? "../ui"), options, handler) } diff --git a/packages/tools/src/pixel/cli/commands.ts b/packages/tools/src/pixel/cli/commands.ts index 6f8dd593..7b95a539 100644 --- a/packages/tools/src/pixel/cli/commands.ts +++ b/packages/tools/src/pixel/cli/commands.ts @@ -7,8 +7,12 @@ * - 2: a usage problem, a missing file, a broken sprite source * - 3: fixable: rules are broken, `ritmo-tools pixel fix` (or editing the source) fixes them * - 4: needs a person: the grid is unsure; look at the preview, or open the recipe in the page + * + * `review`, `notes`, and `resolve` are about what a person said, not the + * art's rules: they exit 0, or 2 on a usage problem. Their lint results are + * warnings to read, never a failure. */ -import { Data, Effect, Option, Path, Runtime } from "effect" +import { Data, Effect, FileSystem, Option, Path, Runtime } from "effect" import { Argument, Command, Flag } from "effect/cli" import type { Metric } from "../core/color.ts" import { toHex } from "../core/color.ts" @@ -24,9 +28,12 @@ import { animationOf, defaultSheet, frameOf, isAnimationName, withFrames, type S import { atlasOf, played, spritesheetJson } from "../core/spritesheet.ts" import { encodeRecipe, type GridChoice, type Outlines, type Recipe } from "../core/recipe.ts" import { checkImage, compareReports, reportOfArt, reportOfFix, reportOfSheet, type Report, type Verdict } from "../core/report.ts" +import { pixelSummary, resolveComments, reviewSummary } from "../core/review.ts" import { artOfImage, formatSprite } from "../core/sprite.ts" import { CliFailure, CONFIG_FILE, decodeOrFail, loadConfig, loadPalette, starterConfig, type Config } from "./config.ts" import { outputBase, printJson, readPicture, readProjectRecipeOf, readSource, readText, writeBytes, writePng, writeProject, writeText } from "./io.ts" +import { findAssets, goneOf, readAsset, readReviewFile, reviewPlace, reviewsOf, writeReview, type PixelAsset } from "./library.ts" +import { serve } from "./serve.ts" /** Ends the run with an exit code, already reported on stdout. */ export class Exit extends Data.TaggedError("Exit")<{ readonly code: number }> { @@ -545,8 +552,132 @@ const init = Command.make( }) ).pipe(Command.withDescription(`Writes a starting ${CONFIG_FILE} (palette, sizes, color limit) in the working directory.`)) + +// The review: a person goes through the art on a page, and the agent reads back what they said. + +const pathsArgument = Argument.String("paths").pipe( + Argument.withDescription( + "PNGs, text sprites, or folders to look through for them. Name the folders the game's art is in (`public/art`, `assets/sprites`): a whole game holds much that is not art, and the tools' own outputs are left out." + ), + Argument.variadic({ min: 1 }) +) + +const promised = (what: string, run: () => Promise): Effect.Effect => + Effect.tryPromise({ try: run, catch: (error) => new CliFailure({ message: `${what}: ${error instanceof Error ? error.message : String(error)}` }) }) + +/** The assets under `paths`, failing on a path that is not there, a folder with no art, and a file named outright that cannot be read. */ +const assetsUnder = Effect.fn("assetsUnder")(function* (paths: ReadonlyArray) { + const fs = yield* FileSystem.FileSystem + for (const path of paths) { + const info = yield* fs.stat(path).pipe(Effect.mapError(() => new CliFailure({ message: `cannot read ${path}` }))) + if (info.type === "Directory") { + if ((yield* promised(`cannot look through ${path}`, () => findAssets([path]))).length === 0) return yield* new CliFailure({ message: `no PNGs or text sprites in ${path}` }) + continue + } + const [asset] = yield* promised(`cannot read ${path}`, () => findAssets([path])) + const got = asset === undefined ? undefined : yield* promised(`cannot read ${path}`, () => readAsset(asset)) + if (got?.ok === false) return yield* new CliFailure({ message: `${path} cannot be read as pixel art: ${got.problem}` }) + } + return yield* promised("cannot look through the paths", () => findAssets(paths)) +}) + +const review = Command.make( + "review", + { + paths: pathsArgument, + port: Flag.Int("port").pipe(Flag.withDescription("The port to serve the page on."), Flag.withDefault(5193)), + host: Flag.Boolean("host").pipe(Flag.withDefault(false), Flag.withDescription("Serve on the network too, to look on a phone.")) + }, + Effect.fn("review")(function* (flags) { + const path = yield* Path.Path + const found = yield* assetsUnder(flags.paths) + // A broken review file is said now, not when the page cannot save. + yield* promised("cannot read the review", () => reviewsOf(found)) + const address = yield* promised("cannot serve the page", () => serve({ paths: flags.paths, port: flags.port, host: flags.host })) + yield* printJson({ + ...address, + assets: found.length, + reviews: [...new Set(found.map((asset) => path.relative(process.cwd(), reviewPlace(asset.file).reviews)))], + next: "Send the person the url. What they approve and comment is saved as they go; read it back with `ritmo-tools pixel notes`, with the same paths, and `pixel resolve` the comments you act on." + }) + return yield* Effect.never + }) +).pipe( + Command.withDescription( + "Opens the review page for all the pixel art under the paths: the person goes through it by folder, at whole-number zooms over a choice of backdrops, plays sheets by animation, sees the whole set side by side at its own size, approves each asset or asks for changes, and pins comments to an area of pixels on a frame. The lint against each asset's nearest pixel.config.json shows beside it. Runs until stopped; changes are saved as they happen." + ) +) + +const notes = Command.make( + "notes", + { paths: pathsArgument }, + Effect.fn("notes")(function* (flags) { + const path = yield* Path.Path + const found = yield* assetsUnder(flags.paths) + const reviews = yield* promised("cannot read the review", () => reviewsOf(found)) + const entries = [] + for (const asset of found) { + const got = yield* promised(`cannot read ${asset.id}`, () => readAsset(asset)) + const said = reviews.assets[asset.id] ?? { status: "new" as const, note: "", comments: [] } + const source = asset.source === undefined ? {} : { source: path.relative(process.cwd(), asset.source) } + if (!got.ok) { + entries.push({ file: asset.id, ...source, ...reviewSummary(said, undefined), problem: got.problem, next: "Fix it: the person cannot see it." }) + continue + } + // The lint as warnings to read, each on its frame: never a failure of the notes. + const lint = got.lint.ok + ? got.lint.issues.length === 0 + ? {} + : { + lint: got.lint.issues.map((issue) => { + const frame = issue.frame === undefined ? undefined : got.frames[issue.frame] + return `${frame === undefined ? "" : `${frame.animation ?? ""} frame ${frame.index}: `}${issue.severity}: ${issue.message}` + }) + } + : { lint: [`cannot lint: ${got.lint.problem}`] } + entries.push({ file: asset.id, ...source, ...reviewSummary(said, got.frames), ...lint }) + } + const gone = yield* promised("cannot read the review", () => goneOf(found, flags.paths)) + yield* printJson({ + ...pixelSummary(entries), + gone: gone.map((entry) => ({ ...entry, next: `Its file is gone. If it was renamed, move its review to the new name's key in ${entry.review}; if it was deleted, remove it there.` })) + }) + }) +).pipe( + Command.withDescription( + "What the person said in the review of the art under the paths: a summary of what is left, then each asset's status, note, open comments (numbered, each on an area of one frame), how many are resolved, and, for an approved asset that no longer looks as the person saw it, `changedSinceApproved`. Changes asked come first. `gone` lists reviews whose file is no longer found. Exit 0 whatever the art's lint says." + ) +) + +const resolve = Command.make( + "resolve", + { + asset: Argument.String("asset").pipe(Argument.withDescription("The asset the comments are on, as `pixel notes` names it.")), + numbers: Argument.Int("numbers").pipe(Argument.withDescription("The comments' numbers, as `pixel notes` lists them."), Argument.atLeast(1)), + reply: Flag.String("reply").pipe(Flag.withDescription("What you changed, in a few words: the person reads it beside their comment."), Flag.withDefault("")) + }, + Effect.fn("resolve")(function* (flags) { + const path = yield* Path.Path + const [asset]: ReadonlyArray = yield* assetsUnder([flags.asset]) + if (asset === undefined) return yield* new CliFailure({ message: `${flags.asset} is not pixel art` }) + const { reviews, key } = reviewPlace(asset.file) + const file = yield* promised("cannot read the review", () => readReviewFile(reviews)) + const resolved = resolveComments(file, key, flags.numbers, flags.reply) + if (!resolved.ok) return yield* new CliFailure({ message: resolved.error }) + const said = resolved.value.assets[key] + if (said !== undefined) yield* promised("cannot write the review", () => writeReview(asset, said)) + yield* printJson({ review: path.relative(process.cwd(), reviews), asset: asset.id, resolved: flags.numbers, reply: flags.reply === "" ? undefined : flags.reply }) + }) +).pipe( + Command.withDescription( + "Marks the person's comments on an asset as resolved, by the numbers `pixel notes` gives, with a reply saying what changed. `pixel notes` stops listing them; the page shows them done, with the reply, even while it is open, and the person can reopen one." + ) +) + export const pixel = Command.make("pixel").pipe( - Command.withDescription("Ritmo's pixel fixer: checks, fixes, and makes pixel art. Prints JSON; exit 0 clean, 2 usage error, 3 fixable, 4 needs a person."), - Command.withSubcommands([check, fix, sheetCommand, pixelate, render, lint, previewCommand, compare, palettes, init]) + Command.withDescription( + "Ritmo's pixel fixer: checks, fixes, and makes pixel art, and opens a review page where a person approves a game's art or pins comments to it. Prints JSON; exit 0 clean, 2 usage error, 3 fixable, 4 needs a person (review, notes, and resolve exit 0 or 2)." + ), + Command.withSubcommands([check, fix, sheetCommand, pixelate, render, lint, previewCommand, compare, palettes, init, review, notes, resolve]) ) diff --git a/packages/tools/src/pixel/cli/library.ts b/packages/tools/src/pixel/cli/library.ts new file mode 100644 index 00000000..2b6e968d --- /dev/null +++ b/packages/tools/src/pixel/cli/library.ts @@ -0,0 +1,372 @@ +/** + * The pixel art a review covers: the pictures and text sprites under the + * paths the agent names, each read as the game would draw it (a still, or a + * sheet of animations), hashed, and linted against its nearest + * `pixel.config.json`, with what the person said about it. The page and + * `pixel notes` both read it here, so they agree on what there is. + * + * A folder's files are found by name, and what the pixel tools write beside + * an asset is left out: previews, project PNGs, onion skins, enlarged copies + * (`@4x`), and a sheet's frames. Where a tool's output sits beside what it + * was made from (`pic.png` and `pic.fixed.png`, `x.sprite.txt` and + * `x.sprite.render.png`), the review shows the output, the file the game + * uses, and names the other as its source. A text file is an asset only + * when it reads as a sprite: prompts and notes sit beside sprites too. A + * file named outright is always taken. + */ +import { existsSync, statSync } from "node:fs" +import { readFile, writeFile } from "node:fs/promises" +import { basename, dirname, extname, join, relative, resolve } from "node:path" +import { NodeServices } from "@effect/platform-node" +import { Effect, Option } from "effect" +import { idOf, posix, rootOf, walk } from "../../internal/files.ts" +import { hashText } from "../../internal/hash.ts" +import { mtime } from "../../internal/serve.ts" +import { toRgba } from "../core/export.ts" +import type { RgbaImage } from "../core/grid.ts" +import type { Severity } from "../core/lint.ts" +import { reportOfArt } from "../core/report.ts" +import { decodeReview, emptyReview, encodeReview, hashFrame, REVIEW_FILE, type AssetReview, type Frame, type Review } from "../core/review.ts" +import { artOfImage, parseJsonMap, parseSprite } from "../core/sprite.ts" +import { readSpritesheet, type SheetAnimationRead } from "../core/spritesheet.ts" +import type { Detail, Entry, FrameIssue } from "../review/store.ts" +import { CONFIG_FILE, loadConfig } from "./config.ts" +import { decodePicture, encodePng } from "./png.ts" + +export interface PixelAsset { + /** How the page names it: its path from the working directory, with `/` between folders. */ + readonly id: string + /** A picture (PNG), or pixel art written as text (a sprite source, or a JSON map named outright). */ + readonly kind: "picture" | "text" + readonly file: string + /** What it was made from, when that sits beside it (`pic.png` for `pic.fixed.png`). */ + readonly source: string | undefined +} + +const PICTURE = /\.(png|jpe?g)$/i +/** What the pixel tools write beside an asset that is not an asset: a preview, a project PNG, an onion skin, an enlarged copy and its JSON. */ +const TOOL_OUTPUT = /(\.(preview|project|onion)\.png|@\d+x\.(png|json))$/i +/** A sheet's frames, written one PNG each. */ +const isFrames = (name: string): boolean => name.endsWith(".frames") +/** The outputs named after what they were made from: `pixel fix`, `sheet`, and `pixelate` (from a picture), `render` (from text). */ +const MADE = /^(.*)\.(fixed|sheet|pixel|render)\.png$/i + +const stemOf = (file: string): string => file.slice(0, file.length - extname(file).length) + +/** Text files that read as sprites, by their mtime: a folder's text is read once until it changes. */ +const sprites = new Map() +const isSprite = async (file: string): Promise => { + const time = await mtime(file) + const known = sprites.get(file) + if (known?.time === time) return known.sprite + const sprite = parseSprite(await readFile(file, "utf8").catch(() => "")).ok + sprites.set(file, { time, sprite }) + return sprite +} + +/** The assets in a folder: its PNGs and text sprites, each output in place of what it was made from. */ +const inFolder = async (folder: string): Promise> => { + const all = await walk(folder, isFrames) + const names = new Set(all) + const pictures = all.filter((file) => /\.png$/i.test(file) && !TOOL_OUTPUT.test(file)) + const texts = all.filter((file) => /\.txt$/i.test(file)) + const sources = new Map() + const covered = new Set() + for (const picture of pictures) { + const made = MADE.exec(picture) + if (made === null) continue + const from = (made[2]!.toLowerCase() === "render" ? [".txt", ".json"] : [".png", ".jpg", ".jpeg"]).map((extension) => made[1]! + extension).find((file) => names.has(file)) + if (from === undefined) continue + sources.set(picture, from) + covered.add(from) + } + // A text sprite beside a PNG of its name (`hero.txt` or `hero.sprite.txt` by `hero.png`) is that PNG again, as text. + const shown = new Set(pictures.filter((picture) => !covered.has(picture))) + for (const text of texts) { + const stem = stemOf(text) + const picture = [`${stem}.png`, `${stem.replace(/\.sprite$/i, "")}.png`].find((file) => shown.has(file)) + if (picture === undefined) continue + covered.add(text) + if (!sources.has(picture)) sources.set(picture, text) + } + const found: Array = [...shown].map((file) => ({ id: idOf(file), kind: "picture", file, source: sources.get(file) })) + for (const text of texts) if (!covered.has(text) && (await isSprite(text))) found.push({ id: idOf(text), kind: "text", file: text, source: undefined }) + return found +} + +/** Every asset under `paths` (files or folders), in path order; a path that is gone is passed over. */ +export const findAssets = async (paths: ReadonlyArray): Promise> => { + const assets = new Map() + for (const path of paths) { + const absolute = resolve(path) + if (!existsSync(absolute)) continue + if (statSync(absolute).isDirectory()) for (const asset of await inFolder(absolute)) assets.set(asset.file, asset) + else assets.set(absolute, { id: idOf(absolute), kind: PICTURE.test(absolute) ? "picture" : "text", file: absolute, source: undefined }) + } + return [...assets.values()].sort((a, b) => a.id.localeCompare(b.id)) +} + +// --------------------------------------------------------------------------- +// Reading an asset as the game draws it + +export type Lint = + | { readonly ok: true; readonly issues: ReadonlyArray; readonly errors: number; readonly warnings: number; readonly config: string | undefined } + | { readonly ok: false; readonly problem: string } + +export type AssetRead = + | { + readonly ok: true + readonly image: RgbaImage + /** The picture as the page draws it: decoded and written again, so it shows exactly the pixels hashed (a text sprite, rendered). */ + readonly png: Uint8Array + readonly frames: ReadonlyArray + readonly animations: ReadonlyArray + /** The sheet's JSON, when it is a sheet. */ + readonly sheet: string | undefined + readonly lint: Lint + } + | { readonly ok: false; readonly problem: string } + +/** The nearest `pixel.config.json` at or above the asset's folder. */ +const configOf = (file: string): string | undefined => { + let folder = dirname(file) + for (;;) { + const config = join(folder, CONFIG_FILE) + if (existsSync(config)) return config + const parent = dirname(folder) + if (parent === folder) return undefined + folder = parent + } +} + +/** Where a picture's sheet JSON would be: beside it, by its name. */ +const sheetFileOf = (asset: PixelAsset): string | undefined => (asset.kind === "picture" ? `${stemOf(asset.file)}.json` : undefined) + +/** Changes when what the asset draws or how it is linted does: its file, its sheet JSON, its config. */ +export const assetVersion = async (asset: PixelAsset): Promise => { + const config = configOf(asset.file) + const sheet = sheetFileOf(asset) + return hashText(`${await mtime(asset.file)}:${sheet === undefined ? 0 : await mtime(sheet)}:${config ?? ""}:${config === undefined ? 0 : await mtime(config)}`) +} + +const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)) + +const configs = new Map[1] } | { readonly ok: false; readonly problem: string }> }>() +const rulesOf = async (config: string) => { + const time = await mtime(config) + const known = configs.get(config) + if (known?.time === time) return known.rules + const rules = Effect.runPromise( + loadConfig(Option.some(config)).pipe( + Effect.map((loaded) => ({ ok: true as const, rules: loaded.rules })), + Effect.catch((failure) => Effect.succeed({ ok: false as const, problem: failure.message })), + Effect.provide(NodeServices.layer) + ) + ) + configs.set(config, { time, rules }) + return rules +} + +/** The part of a picture one frame covers. */ +const crop = (image: RgbaImage, frame: Frame): RgbaImage => { + const data = new Uint8ClampedArray(frame.w * frame.h * 4) + for (let y = 0; y < frame.h; y++) data.set(image.data.subarray(((frame.y + y) * image.width + frame.x) * 4, ((frame.y + y) * image.width + frame.x + frame.w) * 4), y * frame.w * 4) + return { width: frame.w, height: frame.h, data } +} + +const lintOf = async (asset: PixelAsset, image: RgbaImage, frames: ReadonlyArray, art: Parameters[0] | undefined): Promise => { + const config = configOf(asset.file) + const loaded = config === undefined ? { ok: true as const, rules: {} } : await rulesOf(config) + if (!loaded.ok) return { ok: false, problem: loaded.problem } + const issues: Array = [] + if (frames.length === 1 && frames[0]!.animation === undefined) { + // A still: the art as written (a text sprite), or the picture taken at its own size. + const read = art === undefined ? artOfImage(image) : { art, partial: 0 } + issues.push(...reportOfArt(read.art, loaded.rules, read.partial).issues) + } else + for (const [index, frame] of frames.entries()) { + const read = artOfImage(crop(image, frame)) + issues.push(...reportOfArt(read.art, loaded.rules, read.partial).issues.map((issue) => ({ ...issue, frame: index }))) + } + const count = (severity: Severity): number => issues.filter((issue) => issue.severity === severity).length + return { ok: true, issues, errors: count("error"), warnings: count("warning"), config: config === undefined ? undefined : idOf(config) } +} + +const read = async (asset: PixelAsset): Promise => { + try { + if (asset.kind === "text") { + const text = await readFile(asset.file, "utf8") + const parsed = /\.json$/i.test(asset.file) ? parseJsonMap(text) : parseSprite(text) + if (!parsed.ok) return { ok: false, problem: parsed.problems.map((problem) => (problem.line === undefined ? problem.message : `line ${problem.line}: ${problem.message}`)).join("; ") } + const image = toRgba(parsed.art) + const frames: ReadonlyArray = [{ name: "", index: 0, x: 0, y: 0, w: image.width, h: image.height, hash: hashFrame(image, 0, 0, image.width, image.height) }] + return { ok: true, image, png: encodePng(image), frames, animations: [], sheet: undefined, lint: await lintOf(asset, image, frames, parsed.art) } + } + const image = decodePicture(await readFile(asset.file)) + const sheetFile = sheetFileOf(asset) + const json: unknown = sheetFile === undefined ? undefined : await readFile(sheetFile, "utf8").then((text): unknown => JSON.parse(text)).catch(() => undefined) + const sheet = readSpritesheet(json, image) + if (sheet?.ok === false) return { ok: false, problem: `${basename(sheetFile ?? "")}: ${sheet.error}` } + const animations = sheet?.sheet.animations ?? [] + const frames: ReadonlyArray = + sheet === undefined + ? [{ name: "", index: 0, x: 0, y: 0, w: image.width, h: image.height, hash: hashFrame(image, 0, 0, image.width, image.height) }] + : sheet.sheet.frames.map((rect, i) => { + // A frame belongs to the first animation that plays it, at its first place there. + const animation = animations.find((entry) => entry.frames.includes(i)) + return { name: rect.name, animation: animation?.name ?? "frames", index: animation?.frames.indexOf(i) ?? i, x: rect.x, y: rect.y, w: rect.w, h: rect.h, duration: rect.duration, hash: hashFrame(image, rect.x, rect.y, rect.w, rect.h) } + }) + return { ok: true, image, png: encodePng(image), frames, animations, sheet: sheet === undefined ? undefined : sheetFile, lint: await lintOf(asset, image, frames, undefined) } + } catch (error) { + return { ok: false, problem: message(error) } + } +} + +/** Each asset's read, by its version: the page's poll reads nothing again until a file changes, and two polls at once share one read. */ +const reads = new Map }>() +export const readAsset = async (asset: PixelAsset): Promise => { + const version = await assetVersion(asset) + const known = reads.get(asset.file) + if (known?.version === version) return known.read + const fresh = read(asset) + reads.set(asset.file, { version, read: fresh }) + return fresh +} + +// --------------------------------------------------------------------------- +// The review file + +/** Where an asset's review is kept: the shared file at the game's root, and the asset's key in it. */ +export const reviewPlace = (file: string): { readonly reviews: string; readonly key: string } => { + const root = rootOf(file) + return { reviews: join(root, REVIEW_FILE), key: posix(relative(root, file)) } +} + +/** A review file's text, `null` when there is none yet. */ +const textOf = (reviews: string): Promise => readFile(reviews, "utf8").catch(() => null) + +export const readReviewFile = async (reviews: string): Promise => { + const text = await textOf(reviews) + if (text === null) return { assets: {} } + const decoded = decodeReview(text) + if (!decoded.ok) throw new Error(`${reviews}: ${decoded.error}`) + return decoded.value +} + +/** The review files the assets' reviews live in, each with the assets in it. */ +const filesOf = (assets: ReadonlyArray): Map> => { + const files = new Map>() + for (const asset of assets) { + const { reviews } = reviewPlace(asset.file) + files.set(reviews, [...(files.get(reviews) ?? []), asset]) + } + return files +} + +/** Changes when any of the assets' review files does: a hash of what they hold, so the page knows its own write from another. */ +export const reviewVersion = async (assets: ReadonlyArray): Promise => { + const parts: Array = [] + for (const reviews of [...filesOf(assets).keys()].sort()) parts.push(reviews, (await textOf(reviews)) ?? "") + return hashText(parts.join("\u0000")) +} + +/** The assets' reviews by their ids, as the page holds them; throws when a review file is broken. */ +export const reviewsOf = async (assets: ReadonlyArray): Promise => { + const byId: Record = {} + for (const [reviews, inside] of filesOf(assets)) { + const file = await readReviewFile(reviews) + for (const asset of inside) { + const said = file.assets[reviewPlace(asset.file).key] + if (said !== undefined) byId[asset.id] = said + } + } + return { assets: byId } +} + +let writing: Promise = Promise.resolve() + +/** + * Writes the page's reviews (by id) of `assets` into their review files, one + * write at a time: each file keeps every review of an asset not on the page + * (one under another path, one whose file is gone). + */ +export const writeReviews = (assets: ReadonlyArray, review: Review): Promise => { + const write = writing.then(async () => { + for (const [reviews, inside] of filesOf(assets)) { + const file = await readReviewFile(reviews) + const kept = { ...file.assets } + for (const asset of inside) { + const key = reviewPlace(asset.file).key + const said = review.assets[asset.id] + if (said === undefined) delete kept[key] + else kept[key] = said + } + const text = encodeReview({ assets: kept }) + const now = await textOf(reviews) + // Nothing said yet makes no file. + if (text !== now && !(now === null && text === encodeReview(emptyReview))) await writeFile(reviews, text) + } + }) + writing = write.catch(() => undefined) + return write +} + +/** Writes one asset's review into its file, as `pixel resolve` does. */ +export const writeReview = (asset: PixelAsset, said: AssetReview): Promise => writeReviews([asset], { assets: { [asset.id]: said } }) + +/** Reviews in the assets' files whose asset is no longer found: renamed or deleted, so the agent can move them. */ +export const goneOf = async (assets: ReadonlyArray, paths: ReadonlyArray) => { + const found = new Set(assets.map((asset) => asset.file)) + const folders = paths.map((path) => resolve(path)).filter((path) => existsSync(path) && statSync(path).isDirectory()) + const gone: Array<{ readonly file: string; readonly review: string; readonly key: string; readonly status: AssetReview["status"]; readonly note: string | undefined; readonly comments: number }> = [] + for (const reviews of filesOf(assets).keys()) { + const root = dirname(reviews) + for (const [key, said] of Object.entries((await readReviewFile(reviews)).assets)) { + const file = join(root, key) + if (found.has(file)) continue + // Gone: deleted, or no longer an asset inside a folder under review. A review of art elsewhere is not this review's. + if (existsSync(file) && !folders.some((folder) => !relative(folder, file).startsWith(".."))) continue + gone.push({ file: idOf(file), review: idOf(reviews), key, status: said.status, note: said.note === "" ? undefined : said.note, comments: said.comments.filter((comment) => comment.resolved !== true).length }) + } + } + return gone +} + +// --------------------------------------------------------------------------- +// The page's list + +export const entryOf = async (asset: PixelAsset): Promise => { + const version = await assetVersion(asset) + const got = await readAsset(asset) + const base = { id: asset.id, name: basename(asset.file), folder: posix(dirname(asset.id)), source: asset.source === undefined ? undefined : idOf(asset.source), version } + if (!got.ok) return { ...base, kind: "still", width: 0, height: 0, frames: [], animations: [], errors: 0, warnings: 0, lintProblem: undefined, problem: got.problem } + return { + ...base, + kind: got.sheet === undefined ? "still" : "sheet", + width: got.image.width, + height: got.image.height, + frames: got.frames, + animations: got.animations, + errors: got.lint.ok ? got.lint.errors : 0, + warnings: got.lint.ok ? got.lint.warnings : 0, + lintProblem: got.lint.ok ? undefined : got.lint.problem, + problem: undefined + } +} + +/** The page's list, read again only where files changed. */ +export const libraryOf = (assets: ReadonlyArray): Promise> => Promise.all(assets.map(entryOf)) + +/** What the page shows beside one asset: its lint issues, and the files it comes from. */ +export const detailOf = async (asset: PixelAsset): Promise => { + const got = await readAsset(asset) + return { + id: asset.id, + file: asset.file, + source: asset.source === undefined ? undefined : idOf(asset.source), + sheet: got.ok && got.sheet !== undefined ? idOf(got.sheet) : undefined, + config: got.ok && got.lint.ok ? got.lint.config : undefined, + issues: got.ok && got.lint.ok ? got.lint.issues : [], + version: await assetVersion(asset) + } +} diff --git a/packages/tools/src/pixel/cli/serve.ts b/packages/tools/src/pixel/cli/serve.ts new file mode 100644 index 00000000..509fcc04 --- /dev/null +++ b/packages/tools/src/pixel/cli/serve.ts @@ -0,0 +1,79 @@ +/** + * The pixel art review page on localhost, and a small API beside it: the + * list of assets (each read, hashed, and linted here, never in the browser, + * whose canvas may not give back the pixels the file holds), each asset's + * picture as PNG, and the review, read and written whole. The page checks + * the list's version every moment, so art the agent rewrites or adds, and + * comments it resolves, show up while the page is open. + * + * Only what the library finds under the paths the agent named is served: + * an `id` names one of those, never a path to read. + */ +import type { IncomingMessage, ServerResponse } from "node:http" +import { dirname } from "node:path" +import { fileURLToPath } from "node:url" +import { hashText } from "../../internal/hash.ts" +import { body, send, servePage, type Address, type Handler } from "../../internal/serve.ts" +import { decodeReview, encodeReview } from "../core/review.ts" +import { assetVersion, detailOf, findAssets, libraryOf, readAsset, reviewsOf, reviewVersion, writeReviews, type PixelAsset } from "./library.ts" + +export interface ServeOptions { + /** The pictures, text sprites, and folders to review. */ + readonly paths: ReadonlyArray + readonly port: number + readonly host: boolean +} + +/** The API over the assets under `paths`. */ +export const api = (paths: ReadonlyArray): Handler => { + const named = (request: IncomingMessage, assets: ReadonlyArray): PixelAsset | undefined => { + const id = new URL(request.url ?? "/", "http://localhost").searchParams.get("id") + return assets.find((asset) => asset.id === id) + } + const query = (request: IncomingMessage, name: string): string | null => new URL(request.url ?? "/", "http://localhost").searchParams.get(name) + return async (request: IncomingMessage, response: ServerResponse): Promise => { + try { + const path = decodeURIComponent((request.url ?? "/").split("?")[0] ?? "/") + const route = `${request.method ?? "GET"} ${path}` + const assets = await findAssets(paths) + if (route === "GET /library") { + const entries = await libraryOf(assets) + const version = hashText(JSON.stringify(entries.map((entry) => [entry.id, entry.version]))) + const review = await reviewVersion(assets) + // Most looks change nothing: then only the versions travel. + return send(response, 200, query(request, "since") === version ? { version, review } : { version, review, entries }) + } + if (route === "GET /review") return send(response, 200, { text: encodeReview(await reviewsOf(assets)), version: await reviewVersion(assets) }) + if (route === "PUT /review") { + const decoded = decodeReview((await body(request)).toString("utf8")) + if (!decoded.ok) return send(response, 400, { error: decoded.error }) + const unknown = Object.keys(decoded.value.assets).filter((id) => !assets.some((asset) => asset.id === id)) + if (unknown.length > 0) return send(response, 400, { error: `no such asset: ${unknown.join(", ")}` }) + // Written since the page last read it (the agent resolved a comment): the page merges and tries again. + const now = await reviewVersion(assets) + if (query(request, "base") !== now) return send(response, 409, { error: "the review changed since the page read it", text: encodeReview(await reviewsOf(assets)), version: now }) + await writeReviews(assets, decoded.value) + return send(response, 200, { version: await reviewVersion(assets) }) + } + const asset = named(request, assets) + if (asset === undefined) return send(response, 404, { error: `no such asset for ${route}` }) + if (route === "GET /asset") return send(response, 200, await detailOf(asset)) + if (route === "GET /png") { + const got = await readAsset(asset) + if (!got.ok) return send(response, 422, { error: got.problem }) + response.setHeader("content-type", "image/png") + // The page asks by version (`v`): a new version is a new address, so the picture at one can be kept. + response.setHeader("cache-control", query(request, "v") === (await assetVersion(asset)) ? "private, max-age=31536000, immutable" : "no-store") + response.end(got.png) + return + } + return send(response, 404, { error: `no ${route}` }) + } catch (error) { + return send(response, 500, { error: error instanceof Error ? error.message : String(error) }) + } + } +} + +/** Starts the page; resolves with its address once it listens. */ +export const serve = (options: ServeOptions): Promise
=> + servePage({ from: dirname(fileURLToPath(import.meta.url)), source: "../review", name: "pixel-review-api", port: options.port, host: options.host }, api(options.paths)) diff --git a/packages/tools/test/pixel-review.test.ts b/packages/tools/test/pixel-review.test.ts new file mode 100644 index 00000000..60811a42 --- /dev/null +++ b/packages/tools/test/pixel-review.test.ts @@ -0,0 +1,463 @@ +import { spawnSync } from "node:child_process" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, utimesSync, writeFileSync } from "node:fs" +import { createServer, type Server } from "node:http" +import type { AddressInfo } from "node:net" +import { tmpdir } from "node:os" +import { join, relative } from "node:path" +import { fileURLToPath } from "node:url" +import pngjs from "pngjs" +import { describe, expect, it } from "vitest" + +import { decodePng, encodePng } from "@typeonce/ritmo-tools/pixel/cli/png.ts" +import { findAssets, goneOf, libraryOf, readAsset, reviewPlace, reviewsOf, writeReview, writeReviews } from "@typeonce/ritmo-tools/pixel/cli/library.ts" +import { api } from "@typeonce/ritmo-tools/pixel/cli/serve.ts" +import type { RgbaImage } from "@typeonce/ritmo-tools/pixel/core/grid.ts" +import { approvalOf, changedOf, changedSince, decodeReview, encodeReview, hashFrame, mergeReviews, newAsset, pixelSummary, resolveComments, reviewSummary, whereOf, type Frame, type Review } from "@typeonce/ritmo-tools/pixel/core/review.ts" +import { atlasOf, readSpritesheet, spritesheetJson } from "@typeonce/ritmo-tools/pixel/core/spritesheet.ts" +import { TRANSPARENT, type PixelArt } from "@typeonce/ritmo-tools/pixel/core/fix.ts" + +const cli = fileURLToPath(new URL("../bin/ritmo-tools.mjs", import.meta.url)) + +/** A picture `width` by `height`, each pixel colored by `color(x, y)` (`undefined` for transparent). */ +const picture = (width: number, height: number, color: (x: number, y: number) => readonly [number, number, number] | undefined): RgbaImage => { + const data = new Uint8ClampedArray(width * height * 4) + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) { + const c = color(x, y) + if (c !== undefined) data.set([...c, 255], (y * width + x) * 4) + } + return { width, height, data } +} + +const coin = (shade = 200): RgbaImage => picture(8, 8, (x, y) => ((x - 3.5) ** 2 + (y - 3.5) ** 2 < 10 ? [shade, 180, 40] : undefined)) + +const sprite = `palette +. transparent +K #1a1c2c +Y #ffcd75 + +pixels +.KK. +KYYK +.KK. +` + +/** A sheet `pixel sheet` would write: two animations of 4×4 frames, with its JSON. */ +const sheet = (walkDuration = 1): { readonly png: Uint8Array; readonly json: string } => { + const frame = (v: number): PixelArt => ({ width: 4, height: 4, palette: [{ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 }], indices: Int32Array.from({ length: 16 }, (_, i) => (i === v ? 1 : i % 5 === 0 ? 0 : TRANSPARENT)) }) + const clips = [ + { name: "walk", frames: [frame(1), frame(2), frame(3)], anchor: { x: 2, y: 3 }, fps: 10, beats: [1, walkDuration, 1], loop: "forward" as const }, + { name: "idle", frames: [frame(6), frame(7)], anchor: { x: 2, y: 3 }, fps: 4, beats: [1, 1], loop: "pingpong" as const } + ] + const atlas = atlasOf(clips, { columns: 8, spacing: 0 }) + const image: RgbaImage = { width: atlas.art.width, height: atlas.art.height, data: new Uint8ClampedArray(atlas.art.width * atlas.art.height * 4) } + atlas.art.indices.forEach((index, i) => { + const color = atlas.art.palette[index] + if (index !== TRANSPARENT && color !== undefined) image.data.set([color.r, color.g, color.b, 255], i * 4) + }) + return { png: encodePng(image), json: spritesheetJson({ image: "hero.sheet.png", clips, atlas, scale: 1 }) } +} + +/** A game folder with its art, its tools' outputs beside it, and files that are not art. */ +const game = (): string => { + const folder = realpathSync(mkdtempSync(join(tmpdir(), "pixel-review-"))) + const write = (name: string, contents: string | Uint8Array): void => { + mkdirSync(join(folder, name, ".."), { recursive: true }) + writeFileSync(join(folder, name), contents) + } + write("package.json", "{}") + write("public/art/coin.png", encodePng(coin())) + write("public/art/coin.preview.png", encodePng(coin())) + write("public/art/coin@4x.png", encodePng(coin())) + write("public/art/coin@4x.json", "{}") + write("public/art/hero.png", encodePng(coin(90))) + write("public/art/hero.fixed.png", encodePng(coin(100))) + write("public/art/hero.fixed.sprite.txt", sprite) + write("public/art/hero.fixed.json", '{"width": 8, "height": 8, "palette": [], "pixels": []}') + write("public/art/hero.project.png", encodePng(coin(90))) + const { png, json } = sheet() + write("public/art/hero.sheet.png", png) + write("public/art/hero.sheet.json", json) + write("public/art/hero.sheet.idle.onion.png", png) + write("public/art/hero.sheet.frames/walk/0.png", png) + // Still Time's atlas: a JSON beside the picture in a format of its own. + write("public/art/atlas.png", encodePng(coin(60))) + write("public/art/atlas.json", JSON.stringify({ image: "atlas.png", frameSize: [8, 8], anchor: [4, 7], frames: { down: [0, 1] } })) + write("art/sprites/personA.txt", sprite) + write("art/sprites/lamp.sprite.txt", sprite) + write("art/sprites/lamp.sprite.render.png", encodePng(coin(150))) + write("art/sheet.txt", "Notes on the sheet: person A walks left.\n") + write("art/prompt.txt", "A cozy village at dusk.\n") + write("art/node_modules/dep/icon.png", encodePng(coin())) + write("art/.cache/old.png", encodePng(coin())) + write("art/pixel.config.json", JSON.stringify({ palette: ["#1a1c2c", "#ffcd75"], sizes: ["8x8"] })) + write("pixel.config.json", JSON.stringify({ palette: "sweetie-16" })) + return folder +} + +const ids = async (folder: string, paths: ReadonlyArray) => (await findAssets(paths.map((path) => join(folder, path)))).map((asset) => [relative(folder, asset.file), asset.source === undefined ? undefined : relative(folder, asset.source)]) + +describe("finding a game's art", () => { + it("takes PNGs and text sprites in a folder, the tools' outputs left out and each pair shown once", async () => { + const folder = game() + expect(await ids(folder, ["public", "art"])).toEqual([ + ["art/sprites/lamp.sprite.render.png", "art/sprites/lamp.sprite.txt"], + ["art/sprites/personA.txt", undefined], + ["public/art/atlas.png", undefined], + ["public/art/coin.png", undefined], + ["public/art/hero.fixed.png", "public/art/hero.png"], + ["public/art/hero.sheet.png", "public/art/hero.png"] + ]) + }) + + it("takes a file named outright, whatever its name, once", async () => { + const folder = game() + expect(await ids(folder, ["public/art/coin@4x.png", "public/art/coin@4x.png", "art/sheet.txt"])).toEqual([ + ["art/sheet.txt", undefined], + ["public/art/coin@4x.png", undefined] + ]) + // Named outright, a text file that is not a sprite is broken, not skipped. + const [notes] = await findAssets([join(folder, "art/sheet.txt")]) + expect(notes === undefined ? undefined : (await readAsset(notes)).ok).toBe(false) + }) + + it("passes over a path that is gone", async () => { + expect(await findAssets([join(game(), "nothing")])).toEqual([]) + }) + + it("reads a sheet by its JSON, and a picture whose JSON is not a sheet's as a still", async () => { + const folder = game() + const assets = await libraryOf(await findAssets([join(folder, "public")])) + const hero = assets.find((entry) => entry.name === "hero.sheet.png") + expect(hero?.kind).toBe("sheet") + expect(hero?.animations.map((animation) => [animation.name, animation.frames, animation.play])).toEqual([ + ["walk", [0, 1, 2], [0, 1, 2]], + ["idle", [3, 4], [3, 4]] + ]) + expect(hero?.frames.map((frame) => [frame.animation, frame.index, frame.duration])).toEqual([ + ["walk", 0, 100], + ["walk", 1, 100], + ["walk", 2, 100], + ["idle", 0, 250], + ["idle", 1, 250] + ]) + const atlas = assets.find((entry) => entry.name === "atlas.png") + expect([atlas?.kind, atlas?.frames.length, atlas?.problem]).toEqual(["still", 1, undefined]) + }) + + it("lints each asset against its nearest pixel.config.json, each frame of a sheet apart", async () => { + const folder = game() + const [person] = await findAssets([join(folder, "art/sprites/personA.txt")]) + const read = person === undefined ? undefined : await readAsset(person) + // art/pixel.config.json allows 8×8 only, and its palette has both colors: the size is the one rule broken. + expect(read?.ok === true && read.lint.ok ? [read.lint.issues.map((issue) => issue.rule), read.lint.config] : read).toEqual([["size"], relative(process.cwd(), join(folder, "art/pixel.config.json"))]) + const [hero] = await findAssets([join(folder, "public/art/hero.sheet.png")]) + const sheetRead = hero === undefined ? undefined : await readAsset(hero) + // Linted against sweetie-16 at the root, each of the five frames apart: pure black and white are off its palette in each. + const issues = sheetRead?.ok === true && sheetRead.lint.ok ? sheetRead.lint.issues.filter((issue) => issue.rule === "off-palette") : [] + expect(issues.map((issue) => issue.frame)).toEqual([0, 1, 2, 3, 4]) + }) + + it("says why a sheet whose frames are off its picture cannot be read, and goes on", async () => { + const folder = game() + const { json } = sheet() + writeFileSync(join(folder, "public/art/coin.json"), json) + const entries = await libraryOf(await findAssets([join(folder, "public")])) + expect(entries.find((entry) => entry.name === "coin.png")?.problem).toContain("is not on the 8×8 picture") + expect(entries.find((entry) => entry.name === "hero.sheet.png")?.problem).toBeUndefined() + }) +}) + +describe("the review file", () => { + it("lives at the game's root, keyed by the path from there, out of the folder that ships", () => { + const folder = game() + expect(reviewPlace(join(folder, "public/art/coin.png"))).toEqual({ reviews: join(folder, "pixel.review.json"), key: "public/art/coin.png" }) + const loose = realpathSync(mkdtempSync(join(tmpdir(), "pixel-review-"))) + expect(reviewPlace(join(loose, "coin.png")).reviews).toBe(join(loose, "pixel.review.json")) + }) + + it("round-trips, leaving out what says nothing, and names every problem", () => { + const review: Review = { + assets: { + "public/art/coin.png": { status: "approved", note: "", comments: [], approved: { frames: ["0badf00d"] } }, + "public/art/hero.sheet.png": { + status: "changes", + note: "Thinner outline.", + comments: [{ animation: "walk", frame: 2, x: 1, y: 0, width: 2, height: 3, text: "Too dark", resolved: true, reply: "Lighter now" }], + approved: undefined + }, + "public/art/untouched.png": newAsset + } + } + const decoded = decodeReview(encodeReview(review)) + expect(decoded.ok && Object.keys(decoded.value.assets)).toEqual(["public/art/coin.png", "public/art/hero.sheet.png"]) + expect(decoded.ok && decoded.value.assets["public/art/hero.sheet.png"]).toEqual({ ...review.assets["public/art/hero.sheet.png"], approved: undefined }) + const broken = decodeReview(JSON.stringify({ version: 1, assets: { a: { status: "maybe", comments: [{ frame: -1, x: 0, y: 0, text: "" }] }, b: { approved: { frames: [1] } } } })) + expect(broken.ok ? "" : broken.error).toBe('a.status must be one of new, approved, changes; a.comments[0] must be {"frame", "x", "y", "text"}, whole numbers from 0; b.approved must be {"frames": ["hash", ...], "durations": [ms, ...]}') + expect(decodeReview("[]").ok).toBe(false) + }) + + it("keeps every review in the file, of art not under review too, when the page writes its own", async () => { + const folder = game() + const all = await findAssets([folder]) + await writeReview(all.find((asset) => asset.file.endsWith("personA.txt"))!, { status: "changes", note: "Taller.", comments: [] }) + const shown = await findAssets([join(folder, "public")]) + const coin = shown.find((asset) => asset.file.endsWith("coin.png"))! + await writeReviews(shown, { assets: { [coin.id]: { status: "approved", note: "", comments: [] } } }) + const file = decodeReview(readFileSync(join(folder, "pixel.review.json"), "utf8")) + expect(file.ok && Object.fromEntries(Object.entries(file.value.assets).map(([key, said]) => [key, said.status]))).toEqual({ "art/sprites/personA.txt": "changes", "public/art/coin.png": "approved" }) + expect((await reviewsOf(shown)).assets[coin.id]?.status).toBe("approved") + }) + + it("writes no file while nothing is said", async () => { + const folder = game() + const shown = await findAssets([join(folder, "public")]) + await writeReviews(shown, { assets: {} }) + expect(existsSync(join(folder, "pixel.review.json"))).toBe(false) + }) + + it("lists a review whose file is gone, and leaves alone one of art elsewhere", async () => { + const folder = game() + const all = await findAssets([folder]) + await writeReview(all.find((asset) => asset.file.endsWith("personA.txt"))!, { status: "changes", note: "Taller.", comments: [{ frame: 0, x: 1, y: 1, text: "Eyes" }] }) + await writeReview(all.find((asset) => asset.file.endsWith("coin.png"))!, { status: "approved", note: "", comments: [] }) + writeFileSync(join(folder, "public/art/coin.png"), "") + const { rmSync } = await import("node:fs") + rmSync(join(folder, "public/art/coin.png")) + const shown = await findAssets([join(folder, "public")]) + expect((await goneOf(shown, [join(folder, "public")])).map((gone) => [gone.key, gone.status])).toEqual([["public/art/coin.png", "approved"]]) + }) +}) + +describe("what an approval keeps", () => { + const framesOf = (image: RgbaImage): ReadonlyArray => [{ name: "", index: 0, x: 0, y: 0, w: image.width, h: image.height, hash: hashFrame(image, 0, 0, image.width, image.height) }] + + it("is the decoded pixels: a re-export that looks the same is no change, and neither is a color hidden under transparent pixels", () => { + const image = coin() + const approved = approvalOf(framesOf(image)) + // The same pixels written another way: other bytes, and an RGB value under each transparent pixel. + const png = pngjs.PNG.sync.read(Buffer.from(encodePng(image))) + for (let i = 0; i < png.data.length; i += 4) if (png.data[i + 3] === 0) png.data.set([255, 0, 255], i) + const again = pngjs.PNG.sync.write(png, { deflateLevel: 1, filterType: 4 }) + expect(Buffer.compare(again, Buffer.from(encodePng(image)))).not.toBe(0) + expect(changedSince(approved, framesOf(decodePng(again)))).toBeUndefined() + expect(changedSince(approved, framesOf(coin(201)))).toBe("it looks different") + // A pixel that was transparent and is now opaque, in a color that matches the hidden one, is a change. + const filled = coin() + filled.data.set([0, 0, 0, 255], 0) + expect(changedSince(approved, framesOf(filled))).toBe("it looks different") + }) + + it("says which frames of a sheet look different, and which show for longer", () => { + const frames = (walk: ReadonlyArray, durations: ReadonlyArray): ReadonlyArray => + walk.map((hash, index) => ({ name: `walk ${index}`, animation: "walk", index, x: index * 4, y: 0, w: 4, h: 4, duration: durations[index], hash })) + const approved = approvalOf(frames(["a", "b", "c", "d", "e"], [100, 100, 100, 100, 100])) + expect(approved).toEqual({ frames: ["a", "b", "c", "d", "e"], durations: [100, 100, 100, 100, 100] }) + expect(changedSince(approved, frames(["a", "b", "c", "d", "e"], [100, 100, 100, 100, 100]))).toBeUndefined() + expect(changedSince(approved, frames(["a", "x", "y", "z", "e"], [100, 100, 100, 100, 100]))).toBe("walk frames 1-3 look different") + expect(changedSince(approved, frames(["a", "b", "c", "d", "e"], [100, 100, 200, 100, 100]))).toBe("walk frame 2 shows for 200 ms, 100 when approved") + expect(changedSince(approved, frames(["a", "b", "c", "d", "e"], [100, 150, 150, 100, 100]))).toBe("the timing of walk frames 1-2 changed") + expect(changedSince(approved, frames(["a", "b", "c"], [100, 100, 100]))).toBe("3 frames, 5 when approved") + }) + + it("is only compared while approved, and not at all from a review with no hashes", () => { + const frames: ReadonlyArray = [{ name: "", index: 0, x: 0, y: 0, w: 1, h: 1, hash: "a" }] + expect(changedOf({ status: "approved", note: "", comments: [], approved: { frames: ["b"] } }, frames)).toBe("it looks different") + expect(changedOf({ status: "approved", note: "", comments: [] }, frames)).toBeUndefined() + expect(changedOf({ status: "changes", note: "", comments: [], approved: { frames: ["b"] } }, frames)).toBeUndefined() + }) +}) + +describe("comments", () => { + it("name their place in words", () => { + expect(whereOf({ frame: 0, x: 3, y: 4, text: "" })).toBe("pixel 3,4") + expect(whereOf({ frame: 0, x: 3, y: 4, width: 4, height: 2, text: "" })).toBe("pixels 3,4 to 6,5") + expect(whereOf({ animation: "walk", frame: 2, x: 0, y: 0, text: "" })).toBe("walk frame 2, pixel 0,0") + }) + + it("resolve by number with a reply, and fail on a number there is no comment for", () => { + const review: Review = { assets: { a: { status: "changes", note: "", comments: [{ frame: 0, x: 0, y: 0, text: "one" }, { frame: 0, x: 1, y: 1, text: "two" }] } } } + const resolved = resolveComments(review, "a", [2], "Fixed") + expect(resolved.ok && resolved.value.assets.a?.comments.map((comment) => [comment.resolved, comment.reply])).toEqual([ + [undefined, undefined], + [true, "Fixed"] + ]) + expect(resolveComments(review, "a", [3], "")).toEqual({ ok: false, error: "a has no comment 3 (it has 2)" }) + }) + + it("merge the page's changes with a resolve the agent wrote meanwhile", () => { + const one = { frame: 0, x: 0, y: 0, text: "one" } + const base: Review = { assets: { a: { status: "changes", note: "", comments: [one] }, b: { status: "new", note: "x", comments: [] } } } + const theirs: Review = { assets: { ...base.assets, a: { status: "changes", note: "", comments: [{ ...one, resolved: true, reply: "Done" }] } } } + // The page changed both: a note on a, and b approved. + const mine: Review = { assets: { a: { status: "changes", note: "Also the eyes.", comments: [one] }, b: { status: "approved", note: "x", comments: [] } } } + const merged = mergeReviews(base, mine, theirs) + expect(merged.assets.a).toEqual({ status: "changes", note: "Also the eyes.", comments: [{ ...one, resolved: true, reply: "Done" }] }) + expect(merged.assets.b?.status).toBe("approved") + // Only the agent changed it: its version stands. + expect(mergeReviews(base, base, theirs).assets.a?.comments[0]?.resolved).toBe(true) + }) + + it("sum up for the agent: open comments numbered, the changes first", () => { + const said = reviewSummary({ status: "changes", note: "", comments: [{ frame: 0, x: 0, y: 0, text: "done", resolved: true }, { frame: 0, x: 2, y: 2, text: "open" }] }, undefined) + expect(said.comments?.map((comment) => [comment.number, comment.where, comment.text])).toEqual([[2, "pixel 2,2", "open"]]) + expect(said.resolved).toBe(1) + const order = pixelSummary([ + { file: "approved", status: "approved" as const }, + { file: "new", status: "new" as const }, + { file: "stale", status: "approved" as const, changedSinceApproved: "it looks different" }, + { file: "changes", status: "changes" as const } + ]) + expect(order.assets.map((entry) => entry.file)).toEqual(["changes", "stale", "new", "approved"]) + expect(order.summary).toBe("2 approved (1 changed since: ask the person to look again), 1 with changes asked, 1 not reviewed yet (of 4).") + }) +}) + +describe("sheet JSON", () => { + it("reads Aseprite's array format and its frame tags, played the way each says", () => { + const frames = [0, 1, 2, 3].map((i) => ({ filename: `f${i}`, frame: { x: i * 2, y: 0, w: 2, h: 2 }, duration: 50 })) + const read = readSpritesheet({ frames, meta: { frameTags: [{ name: "bounce", from: 0, to: 2, direction: "pingpong" }, { name: "back", from: 3, to: 3, direction: "reverse" }] } }, { width: 8, height: 2 }) + expect(read?.ok === true && read.sheet.animations).toEqual([ + { name: "bounce", frames: [0, 1, 2], play: [0, 1, 2, 1] }, + { name: "back", frames: [3], play: [3] } + ]) + }) + + it("is not a sheet's when its frames have no boxes", () => { + expect(readSpritesheet({ image: "atlas.png", frameSize: [8, 8], frames: { down: [0, 1] } }, { width: 8, height: 8 })).toBeUndefined() + expect(readSpritesheet({ width: 8, height: 8, palette: [], pixels: [] }, { width: 8, height: 8 })).toBeUndefined() + }) +}) + +describe("the review page's server", () => { + const withServer = async (paths: ReadonlyArray, run: (url: string) => Promise): Promise => { + const server: Server = createServer((request, response) => void api(paths)(request, response)) + await new Promise((done) => server.listen(0, "127.0.0.1", done)) + try { + await run(`http://127.0.0.1:${(server.address() as AddressInfo).port}`) + } finally { + server.close() + } + } + const idOf = (file: string): string => relative(process.cwd(), file) + + it("lists the art, and opens each by its id", async () => { + const folder = game() + await withServer([join(folder, "public")], async (url) => { + const listed = (await (await fetch(`${url}/library?since=`)).json()) as { version: string; review: string; entries: ReadonlyArray<{ id: string; kind: string }> } + expect(listed.entries.map((entry) => [entry.id.split("/").pop(), entry.kind])).toEqual([ + ["atlas.png", "still"], + ["coin.png", "still"], + ["hero.fixed.png", "still"], + ["hero.sheet.png", "sheet"] + ]) + // Nothing changed: only the versions come back. + expect(await (await fetch(`${url}/library?since=${listed.version}`)).json()).toEqual({ version: listed.version, review: listed.review }) + const id = encodeURIComponent(idOf(join(folder, "public/art/coin.png"))) + const detail = (await (await fetch(`${url}/asset?id=${id}`)).json()) as { issues: ReadonlyArray; config: string } + expect(detail.config).toBe(idOf(join(folder, "pixel.config.json"))) + const png = await fetch(`${url}/png?id=${id}`) + expect(png.headers.get("content-type")).toBe("image/png") + expect(decodePng(new Uint8Array(await png.arrayBuffer()))).toEqual(coin()) + }) + }) + + it("serves nothing but the art it found", async () => { + const folder = game() + await withServer([join(folder, "public")], async (url) => { + for (const id of [idOf(join(folder, "art/sprites/personA.txt")), idOf(join(folder, "public/art/coin@4x.png")), "../../etc/passwd", "/etc/passwd", ""]) { + expect((await fetch(`${url}/png?id=${encodeURIComponent(id)}`)).status, id).toBe(404) + expect((await fetch(`${url}/asset?id=${encodeURIComponent(id)}`)).status, id).toBe(404) + } + expect((await fetch(`${url}/../../etc/passwd`)).status).toBe(404) + const review = encodeReview({ assets: { "../../etc/passwd": { status: "approved", note: "", comments: [] } } }) + const { review: base } = (await (await fetch(`${url}/library?since=`)).json()) as { review: string } + expect((await fetch(`${url}/review?base=${base}`, { method: "PUT", body: review })).status).toBe(400) + }) + }) + + it("saves the whole review, and refuses one written over a newer file, with what it holds now", async () => { + const folder = game() + await withServer([join(folder, "public")], async (url) => { + const coin = idOf(join(folder, "public/art/coin.png")) + const { version } = (await (await fetch(`${url}/review`)).json()) as { version: string } + const mine = encodeReview({ assets: { [coin]: { status: "changes", note: "Rounder.", comments: [{ frame: 0, x: 1, y: 1, text: "Flat here" }] } } }) + const saved = await fetch(`${url}/review?base=${version}`, { method: "PUT", body: mine }) + expect(saved.status).toBe(200) + const after = ((await saved.json()) as { version: string }).version + const file = decodeReview(readFileSync(join(folder, "pixel.review.json"), "utf8")) + expect(file.ok && file.value.assets["public/art/coin.png"]?.note).toBe("Rounder.") + // What the list says the review is now is what the save said: the page does not take its own save for the agent's. + expect(((await (await fetch(`${url}/library?since=`)).json()) as { review: string }).review).toBe(after) + // Saved again from the old version: refused, with the file as it is. + const stale = await fetch(`${url}/review?base=${version}`, { method: "PUT", body: encodeReview({ assets: {} }) }) + expect(stale.status).toBe(409) + const conflict = (await stale.json()) as { text: string; version: string } + expect(conflict.version).toBe(after) + expect(decodeReview(conflict.text)).toEqual({ ok: true, value: { assets: { [coin]: { status: "changes", note: "Rounder.", comments: [{ frame: 0, x: 1, y: 1, text: "Flat here" }] } } } }) + expect((await fetch(`${url}/review?base=${after}`, { method: "PUT", body: "not json" })).status).toBe(400) + }) + }) + + it("follows art the agent rewrites: a new version, and the new picture", async () => { + const folder = game() + await withServer([join(folder, "public")], async (url) => { + const first = (await (await fetch(`${url}/library?since=`)).json()) as { version: string; entries: ReadonlyArray<{ name: string; version: string }> } + const file = join(folder, "public/art/coin.png") + writeFileSync(file, encodePng(coin(10))) + utimesSync(file, new Date(), new Date(Date.now() + 5000)) + const later = (await (await fetch(`${url}/library?since=${first.version}`)).json()) as { version: string; entries: ReadonlyArray<{ name: string; version: string }> } + expect(later.version).not.toBe(first.version) + expect(later.entries.find((entry) => entry.name === "coin.png")?.version).not.toBe(first.entries.find((entry) => entry.name === "coin.png")?.version) + }) + }) +}) + +describe("the command line", () => { + const run = (cwd: string, ...args: Array) => spawnSync(process.execPath, [cli, "pixel", ...args], { cwd, encoding: "utf8" }) + + it("reads back what the person said, the changes first, and resolves comments by number", { timeout: 60_000 }, async () => { + const folder = game() + const all = await findAssets([folder]) + const hero = all.find((asset) => asset.file.endsWith("hero.sheet.png"))! + const heroRead = await readAsset(hero) + await writeReview(hero, { status: "approved", note: "", comments: [], approved: heroRead.ok ? approvalOf(heroRead.frames) : undefined }) + await writeReview(all.find((asset) => asset.file.endsWith("coin.png"))!, { status: "changes", note: "Rounder.", comments: [{ frame: 0, x: 1, y: 2, width: 2, text: "Flat" }, { frame: 0, x: 5, y: 5, text: "Dent" }] }) + // The agent rewrites the sheet with a frame held longer. + const { png, json } = sheet(2) + writeFileSync(join(folder, "public/art/hero.sheet.png"), png) + writeFileSync(join(folder, "public/art/hero.sheet.json"), json) + const notes = run(folder, "notes", "public", "art") + expect(notes.status, notes.stderr).toBe(0) + const answer = JSON.parse(notes.stdout) as { summary: string; assets: ReadonlyArray<{ file: string; status: string; changedSinceApproved?: string; comments?: ReadonlyArray<{ number: number; where: string }>; lint?: ReadonlyArray }>; gone: ReadonlyArray } + expect(answer.summary).toBe("1 approved (1 changed since: ask the person to look again), 1 with changes asked, 4 not reviewed yet (of 6).") + expect(answer.assets.slice(0, 2).map((entry) => [entry.file, entry.status, entry.changedSinceApproved])).toEqual([ + ["public/art/coin.png", "changes", undefined], + ["public/art/hero.sheet.png", "approved", "walk frame 1 shows for 200 ms, 100 when approved"] + ]) + expect(answer.assets[0]?.comments?.map((comment) => [comment.number, comment.where])).toEqual([ + [1, "pixels 1,2 to 2,2"], + [2, "pixel 5,5"] + ]) + expect(answer.assets.find((entry) => entry.file === "art/sprites/personA.txt")?.lint).toEqual(["error: 4×3 is not an allowed size (8×8)"]) + expect(answer.gone).toEqual([]) + const resolved = run(folder, "resolve", "public/art/coin.png", "2", "--reply", "Filled the dent") + expect(resolved.status, resolved.stderr).toBe(0) + const after = JSON.parse(run(folder, "notes", "public").stdout) as { assets: ReadonlyArray<{ file: string; comments?: ReadonlyArray<{ number: number }>; resolved?: number }> } + const coinNow = after.assets.find((entry) => entry.file === "public/art/coin.png") + expect([coinNow?.comments?.map((comment) => comment.number), coinNow?.resolved]).toEqual([[1], 1]) + expect(run(folder, "resolve", "public/art/coin.png", "7").status).toBe(2) + }) + + it("exits 2 on a path that is not there, a folder with no art, and a text file named outright that is not a sprite", { timeout: 60_000 }, () => { + const folder = game() + mkdirSync(join(folder, "empty")) + writeFileSync(join(folder, "empty/readme.txt"), "Not art.\n") + for (const args of [["notes", "nowhere"], ["notes", "empty"], ["notes", "art/sheet.txt"], ["review", "empty"]]) { + const result = run(folder, ...args) + expect(result.status, args.join(" ")).toBe(2) + } + // Lint problems are warnings here, never the exit code. + expect(run(folder, "notes", "art/sprites/personA.txt").status).toBe(0) + }) +}) From 3116840fd7488de425a423bab7a928ed64ef61e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 22:13:37 +0000 Subject: [PATCH 4/9] ritmo-tools pixel: the review page Every asset by folder with its status, one asset at a whole-number zoom over a backdrop of the person's choosing, sheets playing by animation, and the whole set side by side at its own size. Dragging over the art pins a comment to that area, and the agent's lint results sit beside it. Each panel redraws on its own, so typing is never interrupted when the agent writes, and the review stays owed until the server has it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HendNec6exyZNa2zBVD977 --- packages/tools/src/pixel/review/index.html | 25 + packages/tools/src/pixel/review/lab.ts | 1472 ++++++++++++++++++++ packages/tools/src/pixel/review/main.ts | 5 + packages/tools/src/pixel/review/store.ts | 126 ++ packages/tools/src/pixel/review/style.css | 327 +++++ 5 files changed, 1955 insertions(+) create mode 100644 packages/tools/src/pixel/review/index.html create mode 100644 packages/tools/src/pixel/review/lab.ts create mode 100644 packages/tools/src/pixel/review/main.ts create mode 100644 packages/tools/src/pixel/review/store.ts create mode 100644 packages/tools/src/pixel/review/style.css diff --git a/packages/tools/src/pixel/review/index.html b/packages/tools/src/pixel/review/index.html new file mode 100644 index 00000000..3af6c03f --- /dev/null +++ b/packages/tools/src/pixel/review/index.html @@ -0,0 +1,25 @@ + + + + + + Pixel review · Ritmo + + + + + +
+ + + diff --git a/packages/tools/src/pixel/review/lab.ts b/packages/tools/src/pixel/review/lab.ts new file mode 100644 index 00000000..0972e9cb --- /dev/null +++ b/packages/tools/src/pixel/review/lab.ts @@ -0,0 +1,1472 @@ +/** + * The pixel art review page, served by `ritmo-tools pixel review ` + * (`main.ts`): the person goes through a game's art on a list, by folder. + * Each asset opens at its own size (1×) and zooms by whole numbers, over a + * backdrop of their choosing; a sheet plays by animation, with its frames' + * own timings. They approve it, or ask for changes with a note and comments + * pinned to an area of pixels on a frame; the lint against the asset's + * nearest `pixel.config.json` shows beside it. The whole set also shows + * side by side at its own size, to judge the art as one game, and to + * compare and approve several at once. + * + * The review is one file, saved whole as it changes (one undo history for + * all of it), for the agent to read back with `ritmo-tools pixel notes`. Art + * the agent rewrites or adds, and comments it resolves (`pixel resolve`), + * show up while the page is open. Nothing here edits pixels: hand fixes + * belong on the pixel fixer's page, and the agent regenerates art. + * + * Built from the pixel fixer's kit, so the tools look and work alike. + */ +import { button, callout, group, hint, icon, mark, prop, section, segmented, switchRow } from "../ui/kit.ts" +import { h, replace } from "../ui/dom.ts" +import { makeHistory } from "../ui/history.ts" +import { parseHex } from "../core/color.ts" +import { approvalOf, changedOf, decodeReview, emptyReview, encodeReview, mergeReviews, newAsset, whereOf, type AssetReview, type Comment, type Status } from "../core/review.ts" +import { SERVER_GONE, type Detail, type Entry, type FrameIssue, type Store } from "./store.ts" + +const RITMO_URL = "https://ritmo.typeonce.dev/" + +/** Where the art and the review are read and written: set once by `start`. */ +let store!: Store + +// --------------------------------------------------------------------------- +// State + +type Show = "all" | Status +type View = "asset" | "set" +type Backdrop = "dark" | "light" | "checker" | "custom" + +/** An area of one frame, in its pixels: where a comment is pinned. */ +interface Area { + readonly animation: string | undefined + readonly frame: number + readonly x: number + readonly y: number + readonly width: number + readonly height: number +} + +const state = { + /** Every asset, as the server last listed it. */ + library: [] as ReadonlyArray, + libraryVersion: "", + /** The whole review, by asset id, as the page has it now. */ + review: emptyReview, + /** The review as the file held it when the page last read or wrote it: what a merge starts from. */ + base: emptyReview, + reviewVersion: "", + /** Why the review file cannot be read, when it cannot: the page then writes nothing over it. */ + reviewProblem: undefined as string | undefined, + /** The asset open now. */ + current: "", + detail: undefined as Detail | undefined, + view: "asset" as View, + show: "all" as Show, + search: "", + /** The asset's zoom, a whole number: 1 is the art at its own size. */ + zoom: 1, + /** The whole set's zoom. */ + setZoom: 1, + /** Assets picked in the whole set, to compare or approve together. */ + picked: new Set(), + onlyPicked: false, + backdrop: "dark" as Backdrop, + custom: "#3a4466", + /** The frame drawn three by three, to see a tile repeat. */ + repeat: false, + /** The animation open on a sheet, by name. */ + animation: "", + playing: true, + /** The place in the animation shown while paused, from 0. */ + frame: 0, + /** A comment being written, on the area the person drew. */ + composing: undefined as Area | undefined, + /** Pixels ringed from the Rules section: where a lint issue is. */ + marks: undefined as { readonly frame: number; readonly at: ReadonlyArray } | undefined, + saving: "saved" as "saved" | "saving" | "failed", + /** Whether the review server answered the last look. */ + online: true, + loaded: false +} + +const entryOf = (id: string): Entry | undefined => state.library.find((entry) => entry.id === id) +const reviewOf = (id: string): AssetReview => state.review.assets[id] ?? newAsset +const changedNow = (entry: Entry): string | undefined => (entry.problem === undefined ? changedOf(reviewOf(entry.id), entry.frames) : undefined) +const openComments = (id: string): number => reviewOf(id).comments.filter((comment) => comment.resolved !== true).length + +/** An asset's animations; a still is one of one frame, with no name. */ +const animationsOf = (entry: Entry): ReadonlyArray<{ readonly name: string; readonly frames: ReadonlyArray; readonly play: ReadonlyArray }> => + entry.animations.length > 0 ? entry.animations : [{ name: "", frames: [0], play: [0] }] + +const animationOf = (entry: Entry) => animationsOf(entry).find((animation) => animation.name === state.animation) ?? animationsOf(entry)[0]! + +// --------------------------------------------------------------------------- +// Saving: the whole review goes back to its file as it changes + +let dirty = false +let saveTimer: ReturnType | undefined +let flushing: Promise = Promise.resolve() + +const flush = (): Promise => { + flushing = flushing.then(write) + return flushing +} + +const write = async (): Promise => { + saveTimer = undefined + if (!dirty || state.reviewProblem !== undefined) return + // What is on its way stays owed until it lands: a change made meanwhile sets the flag again, and a failure puts it back. + dirty = false + let owed = true + try { + for (let attempt = 0; owed && attempt < 3; attempt++) { + const text = encodeReview(state.review) + const saved = await store.save(text, state.reviewVersion) + if (saved.ok) { + owed = false + state.reviewVersion = saved.version + const sent = decodeReview(text) + if (sent.ok) state.base = sent.value + continue + } + // The agent wrote the review meanwhile (a resolved comment): keep both, and try again. + const theirs = decodeReview(saved.text) + if (!theirs.ok) throw new Error(theirs.error) + state.review = mergeReviews(state.base, state.review, theirs.value) + state.base = theirs.value + state.reviewVersion = saved.version + reviewShown() + } + if (owed) throw new Error("the review kept changing while saving") + state.saving = dirty ? "saving" : "saved" + } catch (error) { + dirty ||= owed + state.saving = "failed" + say(`Not saved: ${error instanceof Error ? error.message : String(error)}`) + } + renderTopbar() +} + +const save = (): void => { + dirty = true + state.saving = "saving" + if (saveTimer !== undefined) clearTimeout(saveTimer) + saveTimer = setTimeout(() => void flush(), 400) +} + +/** Something here is not in the file yet: what it holds now is not to be taken over it. */ +const busy = (): boolean => saveTimer !== undefined || dirty || state.saving === "saving" + +const history = makeHistory( + () => encodeReview(state.review), + (snapshot) => { + const review = decodeReview(snapshot) + if (review.ok) state.review = review.value + save() + reviewShown() + }, + () => renderTopbar() +) + +const setReview = (id: string, change: Partial): void => { + state.review = { assets: { ...state.review.assets, [id]: { ...reviewOf(id), ...change } } } +} + +/** Records a change to the review: undoable, saved, and shown. */ +const reviewChanged = (): void => { + save() + history.schedule() + reviewShown() +} + +/** Sets an asset's status; approving keeps what it looks like now, so a later change to it shows. */ +const setStatus = (entry: Entry, status: Status): void => { + setReview(entry.id, { status, approved: status === "approved" && entry.problem === undefined ? approvalOf(entry.frames) : undefined }) +} + +// --------------------------------------------------------------------------- +// Loading, and following what the agent changes while the page is open + +const pictures = new Map>() + +/** An asset's picture at its version, decoded once. */ +const pictureOf = (entry: Entry): Promise => { + const url = store.picture(entry.id, entry.version) + let found = pictures.get(url) + if (found === undefined) { + const image = new Image() + image.src = url + found = image.decode().then( + () => image, + () => undefined + ) + pictures.set(url, found) + } + return found +} + +let opening = 0 + +/** Opens an asset. Nothing is owed first: the review is one file, saved whole. */ +const open = async (id: string): Promise => { + const entry = entryOf(id) + if (entry === undefined) return + if (id === state.current) { + if (state.view === "set") setView("asset") + return + } + state.current = id + state.view = "asset" + state.animation = animationsOf(entry)[0]!.name + state.frame = 0 + state.playing = true + state.composing = undefined + state.marks = undefined + state.detail = undefined + clock = performance.now() + window.history.replaceState(null, "", `#${encodeURIComponent(id)}`) + update() + scrollToRow(id) + void loadDetail(id) +} + +const loadDetail = async (id: string): Promise => { + const ticket = ++opening + const detail = await store.detail(id) + if (ticket !== opening || id !== state.current) return + state.detail = detail + renderChecks() +} + +const reviewShown = (): void => { + renderTopbar() + renderRail() + renderHead() + renderRings() + if (state.view === "set") renderStage() + renderReview() + renderComments() +} + +const load = async (): Promise => { + const listed = await store.library("") + if (listed?.entries === undefined) return say("The review server sent no art.") + state.library = listed.entries + state.libraryVersion = listed.version + await readReview() + state.loaded = true + history.reset() + history.schedule() + const wanted = decodeURIComponent(location.hash.slice(1)) + const first = entryOf(wanted) ?? state.library[0] + if (first === undefined) return update() + await open(first.id) +} + +const readReview = async (): Promise => { + try { + const answer = await store.review() + const review = decodeReview(answer.text) + if (!review.ok) throw new Error(review.error) + state.review = review.value + state.base = review.value + state.reviewVersion = answer.version + state.reviewProblem = undefined + } catch (error) { + state.reviewProblem = error instanceof Error ? error.message : String(error) + say(`The review cannot be read, so nothing is saved: ${state.reviewProblem}`) + } +} + +let following = false + +const follow = async (): Promise => { + if (following) return + following = true + try { + const listed = await store.library(state.libraryVersion) + const online = listed !== undefined + if (online !== state.online) { + state.online = online + renderTopbar() + if (online && state.saving === "failed") void flush() + } + if (listed === undefined) return + if (listed.entries !== undefined) { + const before = entryOf(state.current)?.version + state.library = listed.entries + state.libraryVersion = listed.version + const now = entryOf(state.current) + renderTopbar() + renderRail() + if (now === undefined) { + if (state.current !== "") say("The asset open is gone: it was renamed or deleted.") + } else if (now.version !== before) { + // The agent rewrote the art open: draw it anew, and read its lint again. + if (!animationsOf(now).some((animation) => animation.name === state.animation)) state.animation = animationsOf(now)[0]!.name + renderStage() + renderReview() + void loadDetail(now.id) + } + if (state.view === "set") renderStage() + } + if (listed.review === state.reviewVersion || busy()) return + const answer = await store.review() + // The person may have changed something while it came. + if (busy() || answer.version === state.reviewVersion) return + const review = decodeReview(answer.text) + if (!review.ok) return + state.review = review.value + state.base = review.value + state.reviewVersion = answer.version + state.reviewProblem = undefined + history.schedule() + say("The review changed in its file (the agent's replies show on the comments).") + reviewShown() + } catch { + // The CLI was stopped; the page keeps what it has. + } finally { + following = false + } +} + +// --------------------------------------------------------------------------- +// Drawing an asset + +/** Empty space around the art, so a ring at its edge shows whole. */ +const MARGIN = 10 + +const BUILT_IN: Readonly, readonly [string, string]>> = { + dark: ["#1a1c2c", "#1f2133"], + light: ["#e8e4d8", "#dcd8cc"], + checker: ["#3a3a44", "#5a5a66"] +} + +/** A backdrop's two square colors: a built-in one, or the person's color with a faintly lighter or darker square, so the pixel size shows. */ +const backdropColors = (): readonly [string, string] => { + if (state.backdrop !== "custom") return BUILT_IN[state.backdrop] + const rgb = parseHex(state.custom) ?? { r: 58, g: 68, b: 102 } + const shift = (rgb.r + rgb.g + rgb.b) / 3 > 128 ? -10 : 10 + const nudge = (v: number): string => Math.max(0, Math.min(255, v + shift)).toString(16).padStart(2, "0") + return [state.custom, `#${nudge(rgb.r)}${nudge(rgb.g)}${nudge(rgb.b)}`] +} + +/** The frame shown now: its index in the sheet's frames, and its place in the animation (as the JSON lists it). */ +const shown = (entry: Entry, now: number): { readonly global: number; readonly place: number } => { + const animation = animationOf(entry) + if (!state.playing || animation.play.length < 2) { + const place = ((state.frame % animation.frames.length) + animation.frames.length) % animation.frames.length + return { global: animation.frames[place]!, place } + } + const durations = animation.play.map((i) => entry.frames[i]?.duration ?? 100) + const total = durations.reduce((sum, d) => sum + d, 0) + let at = (now - clock) % Math.max(1, total) + for (const [step, duration] of durations.entries()) { + if (at < duration) { + const global = animation.play[step]! + return { global, place: animation.frames.indexOf(global) } + } + at -= duration + } + return { global: animation.play[0]!, place: 0 } +} + +let clock = 0 + +/** How many times the frame is drawn across and down: three in the repeat view. */ +const tiles = (): number => (state.repeat ? 3 : 1) + +/** The largest zoom that keeps the canvas a size a browser draws. */ +const maxZoom = (entry: Entry): number => Math.max(1, Math.min(32, Math.floor(4096 / (Math.max(1, ...entry.frames.map((frame) => Math.max(frame.w, frame.h))) * tiles())))) + +/** The canvas's size in page pixels: the largest frame of the animation, at the zoom, with the margin around it. */ +const screenOf = (entry: Entry): { readonly w: number; readonly h: number; readonly fw: number; readonly fh: number } => { + const frames = animationOf(entry).frames.map((i) => entry.frames[i]!) + const fw = Math.max(1, ...frames.map((frame) => frame.w)) + const fh = Math.max(1, ...frames.map((frame) => frame.h)) + return { w: fw * state.zoom * tiles() + MARGIN * 2, h: fh * state.zoom * tiles() + MARGIN * 2, fw, fh } +} + +const paint = (canvas: HTMLCanvasElement, entry: Entry, image: HTMLImageElement, global: number): void => { + const frame = entry.frames[global] + const context = canvas.getContext("2d") + if (frame === undefined || context === null) return + const screen = screenOf(entry) + const ratio = window.devicePixelRatio || 1 + const width = Math.round(screen.w * ratio) + const height = Math.round(screen.h * ratio) + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width + canvas.height = height + } + context.setTransform(ratio, 0, 0, ratio, 0, 0) + context.clearRect(0, 0, screen.w, screen.h) + context.imageSmoothingEnabled = false + const zoom = state.zoom + const [a, b] = backdropColors() + const spanW = screen.fw * tiles() + const spanH = screen.fh * tiles() + // The backdrop is in squares of 4 art pixels, so the pixel size shows. + for (let y = 0; y < spanH; y += 4) + for (let x = 0; x < spanW; x += 4) { + context.fillStyle = (x / 4 + y / 4) % 2 === 0 ? a : b + context.fillRect(MARGIN + x * zoom, MARGIN + y * zoom, Math.min(4, spanW - x) * zoom, Math.min(4, spanH - y) * zoom) + } + for (let ty = 0; ty < tiles(); ty++) + for (let tx = 0; tx < tiles(); tx++) context.drawImage(image, frame.x, frame.y, frame.w, frame.h, MARGIN + tx * screen.fw * zoom, MARGIN + ty * screen.fh * zoom, frame.w * zoom, frame.h * zoom) + if (state.repeat) { + // The middle copy is the one comments are pinned to. + context.strokeStyle = "rgba(255, 255, 255, 0.35)" + context.setLineDash([4, 4]) + context.strokeRect(MARGIN + screen.fw * zoom + 0.5, MARGIN + screen.fh * zoom + 0.5, frame.w * zoom - 1, frame.h * zoom - 1) + context.setLineDash([]) + } +} + +/** The canvas of the asset open, its rings, and what it shows now. */ +let screen: { readonly entry: Entry; readonly canvas: HTMLCanvasElement; readonly rings: HTMLElement; readonly counter: HTMLElement; image: HTMLImageElement | undefined; global: number; place: number } | undefined + +const tick = (now: number): void => { + if (screen !== undefined && screen.image !== undefined && state.view === "asset") { + const at = shown(screen.entry, now) + if (at.global !== screen.global) { + screen.global = at.global + screen.place = at.place + paint(screen.canvas, screen.entry, screen.image, at.global) + renderRings() + renderCounter() + } + } + requestAnimationFrame(tick) +} + +/** Repaints the asset open now (after a zoom, a backdrop, or a new picture). */ +const repaint = (): void => { + if (screen === undefined || screen.image === undefined) return + const at = shown(screen.entry, performance.now()) + screen.global = at.global + screen.place = at.place + paint(screen.canvas, screen.entry, screen.image, at.global) + renderRings() + renderCounter() +} + +/** Magenta: the debug color, in almost no game's palette, so a ring shows on any art. */ +const ringStyle = (area: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }, entry: Entry): string => { + const { fw, fh } = screenOf(entry) + const offsetX = state.repeat ? fw : 0 + const offsetY = state.repeat ? fh : 0 + const zoom = state.zoom + return `left:${MARGIN + (offsetX + area.x) * zoom - 3}px;top:${MARGIN + (offsetY + area.y) * zoom - 3}px;width:${area.width * zoom + 6}px;height:${area.height * zoom + 6}px` +} + +/** The rings over the frame shown: its open comments (numbered), the one being drawn, and a lint issue's pixels. */ +const renderRings = (): void => { + if (screen === undefined) return + const entry = screen.entry + const animation = entry.animations.length > 0 ? animationOf(entry).name : undefined + const place = screen.place + const comments = reviewOf(entry.id).comments.flatMap((comment, index) => + comment.resolved !== true && comment.animation === animation && comment.frame === place ? [{ comment, number: index + 1 }] : [] + ) + const composing = state.composing !== undefined && state.composing.animation === animation && state.composing.frame === place ? state.composing : undefined + const marks = state.marks !== undefined && state.marks.frame === screen.global ? state.marks.at : [] + replace( + screen.rings, + comments.map(({ comment, number }) => + h( + "span", + { class: "pixel-ring", title: comment.text, attrs: { "data-comment": number, style: ringStyle({ x: comment.x, y: comment.y, width: comment.width ?? 1, height: comment.height ?? 1 }, entry) } }, + h("span", { class: "pixel-ring-label" }, String(number)) + ) + ), + composing !== undefined && h("span", { class: "pixel-ring drawing", attrs: { "data-comment": "new", style: ringStyle(composing, entry) } }, h("span", { class: "pixel-ring-label" }, "+")), + marks.map(([x, y]) => h("span", { class: "pixel-ring mark", attrs: { style: ringStyle({ x, y, width: 1, height: 1 }, entry) } })) + ) +} + +const renderCounter = (): void => { + if (screen === undefined) return + const entry = screen.entry + if (entry.animations.length === 0) return replace(screen.counter, `${entry.width}×${entry.height}`) + const animation = animationOf(entry) + const frame = entry.frames[screen.global] + // Frames count from 0, as comments and `pixel notes` count them. + replace(screen.counter, `frame ${screen.place} of ${animation.frames.length} · ${frame?.duration ?? 0} ms`) +} + +/** The pixel of the frame under the pointer, in the middle copy's pixels when it repeats; `undefined` off the art. */ +const pixelAt = (entry: Entry, canvas: HTMLCanvasElement, event: PointerEvent, clamp: boolean): { readonly x: number; readonly y: number } | undefined => { + const box = canvas.getBoundingClientRect() + const frame = entry.frames[screen?.global ?? 0] + if (frame === undefined) return undefined + const { fw, fh } = screenOf(entry) + const zoom = state.zoom + const across = (event.clientX - box.left - MARGIN) / zoom - (state.repeat ? fw : 0) + const down = (event.clientY - box.top - MARGIN) / zoom - (state.repeat ? fh : 0) + // In the repeat view, a pixel of any copy is the middle copy's. + const x = state.repeat ? ((Math.floor(across) % fw) + fw) % fw : Math.floor(across) + const y = state.repeat ? ((Math.floor(down) % fh) + fh) % fh : Math.floor(down) + if (clamp) return { x: Math.max(0, Math.min(frame.w - 1, x)), y: Math.max(0, Math.min(frame.h - 1, y)) } + if (x < 0 || y < 0 || x >= frame.w || y >= frame.h) return undefined + if (state.repeat && (across < -fw || down < -fh || across >= 2 * fw || down >= 2 * fh)) return undefined + return { x, y } +} + +/** Drawing an area to comment on: press on a pixel, drag to another, let go. A press alone is one pixel. */ +const attachPinning = (canvas: HTMLCanvasElement, entry: Entry): void => { + let start: { readonly x: number; readonly y: number } | undefined + const areaTo = (end: { readonly x: number; readonly y: number }): Area => { + const from = start ?? end + const animation = entry.animations.length > 0 ? animationOf(entry).name : undefined + return { animation, frame: screen?.place ?? 0, x: Math.min(from.x, end.x), y: Math.min(from.y, end.y), width: Math.abs(end.x - from.x) + 1, height: Math.abs(end.y - from.y) + 1 } + } + canvas.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return + const at = pixelAt(entry, canvas, event, false) + if (at === undefined) return + // Pause on the frame shown: the comment is about it. + if (state.playing && screen !== undefined) { + state.frame = screen.place + state.playing = false + renderPlayback() + } + start = at + canvas.setPointerCapture(event.pointerId) + state.composing = areaTo(at) + state.marks = undefined + renderRings() + }) + canvas.addEventListener("pointermove", (event) => { + if (start === undefined) return + const at = pixelAt(entry, canvas, event, true) + if (at === undefined) return + state.composing = areaTo(at) + renderRings() + }) + const finish = (event: PointerEvent): void => { + if (start === undefined) return + const at = pixelAt(entry, canvas, event, true) + if (at !== undefined) state.composing = areaTo(at) + start = undefined + renderRings() + renderComments() + requestAnimationFrame(() => document.querySelector(".pixel-compose textarea")?.focus()) + } + canvas.addEventListener("pointerup", finish) + canvas.addEventListener("pointercancel", () => { + start = undefined + }) +} + +// --------------------------------------------------------------------------- +// The page + +const app = document.getElementById("app")! +const topbar = h("header", { class: "topbar" }) +const list = h("aside", { class: "sounds pixel-list", attrs: { "aria-label": "Assets" } }) +const content = h("div", { class: "sound-view pixel-view" }) +const playback = h("div", { class: "floating playback pixel-playback" }) +const toast = h("div", { class: "toast", attrs: { role: "status", "aria-live": "polite" } }) +const panels = h("aside", { class: "inspector", attrs: { "aria-label": "Review" } }) +const stage = h("div", { class: "stage pixel-stage" }, content, playback, toast) + +// Each section draws into its own box, so a change redraws only the section it is about. +const reviewBox = h("div", {}) +const commentsBox = h("div", {}) +const checksBox = h("div", {}) +const viewBox = h("div", {}) +const aboutBox = h("div", {}) +panels.append(reviewBox, commentsBox, checksBox, viewBox, aboutBox) + +app.append(topbar, h("main", { class: "layout lab" }, list, h("section", { class: "workspace" }, stage), panels)) + +export const statusLabel: Record = { new: "To review", approved: "Approved", changes: "Changes asked" } + +const counted = (status: Status): number => state.library.filter((entry) => reviewOf(entry.id).status === status).length + +/** The assets the list and the whole set show: the filter, the search, and in the set, the picked ones when asked. */ +const visible = (): ReadonlyArray => { + const query = state.search.trim().toLowerCase() + return state.library.filter((entry) => (state.show === "all" || reviewOf(entry.id).status === state.show) && (query === "" || entry.id.toLowerCase().includes(query))) +} + +const setView = (view: View): void => { + if (state.view === view) return + state.view = view + state.composing = undefined + update() +} + +const renderTopbar = (): void => { + const total = state.library.length + replace( + topbar, + h( + "div", + { class: "topbar-start" }, + h( + "div", + { class: "brand" }, + mark(), + h("span", {}, "Pixel review"), + h("a", { class: "by", title: "Ritmo, the agent-first type safe ECS runtime", attrs: { href: RITMO_URL, target: "_blank", rel: "noopener" } }, "by ", h("b", {}, "ritmo")) + ), + state.loaded && h("div", { class: "file" }, h("span", { class: "file-name" }, `${total} asset${total === 1 ? "" : "s"}`), h("span", { class: "file-meta" }, `${counted("approved")} approved`)) + ), + h( + "div", + { class: "topbar-center" }, + state.loaded && + segmented( + state.show, + [ + { id: "all", label: `All ${total}` }, + { id: "new", label: `To review ${counted("new")}` }, + { id: "changes", label: `Changes ${counted("changes")}` }, + { id: "approved", label: `Approved ${counted("approved")}` } + ], + (show) => { + state.show = show + renderRail() + renderTopbar() + if (state.view === "set") renderStage() + }, + { label: "Show" } + ), + state.loaded && + segmented( + state.view, + [ + { id: "asset", label: "Asset", icon: "image", title: "One asset at a time, zoomed (Tab: the whole set)" }, + { id: "set", label: "Set", icon: "layers", title: "The whole set side by side at its own size, to judge it as one game (Tab: one asset)" } + ], + setView, + { label: "View", class: "pixel-views" } + ) + ), + h( + "div", + { class: "topbar-end" }, + h( + "div", + { class: "button-group" }, + button("Undo", () => history.undo(), { variant: "ghost", icon: "undo", iconOnly: true, disabled: !history.canUndo(), title: "Undo (Ctrl/Cmd+Z)" }), + button("Redo", () => history.redo(), { variant: "ghost", icon: "redo", iconOnly: true, disabled: !history.canRedo(), title: "Redo (Ctrl/Cmd+Shift+Z)" }) + ), + h("span", { class: "divider" }), + h( + "span", + { + class: `save-state ${state.online ? state.saving : "failed"}`, + title: state.online ? "What you say is written to pixel.review.json as you go" : SERVER_GONE, + attrs: { "data-saving": state.online ? state.saving : "offline" } + }, + state.online && state.saving === "saved" ? icon("check") : state.saving === "failed" || !state.online ? icon("alert") : null, + !state.online ? (dirty || state.saving === "failed" ? "Not saved: server stopped" : "Server stopped") : state.saving === "saved" ? "Saved" : state.saving === "saving" ? "Saving…" : "Not saved" + ) + ) + ) +} + +const rows = h("div", { class: "sound-rows pixel-rows", attrs: { role: "listbox", "aria-label": "Assets" } }) + +const search = h("input", { + props: { type: "search" }, + attrs: { placeholder: "Find an asset", "aria-label": "Find an asset", spellcheck: "false" }, + on: { + input: (event) => { + const target = event.currentTarget + if (!(target instanceof HTMLInputElement)) return + state.search = target.value + renderRail() + if (state.view === "set") renderStage() + } + } +}) + +replace(list, h("div", { class: "sounds-head" }, h("label", { class: "input search" }, icon("search"), search)), rows) + +const statusDot = (status: Status): HTMLElement => h("span", { class: `dot ${status}`, title: statusLabel[status] }) + +/** The little flags on a row: why it cannot be read, a change since it was approved, its lint, its open comments. */ +const flags = (entry: Entry): Array => { + const changed = changedNow(entry) + const comments = openComments(entry.id) + return [ + entry.problem !== undefined && h("span", { class: "sound-flag bad", title: entry.problem, attrs: { "data-flag": "problem" } }, icon("alert")), + changed !== undefined && h("span", { class: "sound-flag warn", title: `Changed since you approved it: ${changed}.`, attrs: { "data-flag": "changed" } }, icon("sparkle")), + entry.problem === undefined && + entry.errors + entry.warnings > 0 && + h("span", { class: `sound-flag ${entry.errors > 0 ? "bad" : "warn"} lint`, title: `${entry.errors} lint error${entry.errors === 1 ? "" : "s"}, ${entry.warnings} warning${entry.warnings === 1 ? "" : "s"}`, attrs: { "data-flag": "lint" } }, icon("info")), + comments > 0 && h("span", { class: "sound-takes", title: `${comments} open comment${comments === 1 ? "" : "s"}`, attrs: { "data-flag": "comments" } }, String(comments)) + ] +} + +const renderRail = (): void => { + const scroll = rows.scrollTop + const entries = visible() + const folders = new Set(state.library.map((entry) => entry.folder)) + let folder: string | undefined + replace( + rows, + entries.length === 0 && h("p", { class: "hint empty-list" }, state.loaded ? "Nothing here." : "Reading the art…"), + entries.flatMap((entry) => { + const heading = folders.size > 1 && entry.folder !== folder ? h("h3", { class: "pixel-folder", title: entry.folder }, entry.folder) : false + folder = entry.folder + const on = entry.id === state.current + return [ + heading, + h( + "div", + { + class: `sound-row${on ? " on" : ""}`, + title: entry.id, + attrs: { role: "option", "aria-selected": String(on), "data-asset": entry.id, tabindex: 0 }, + on: { + click: () => void open(entry.id), + keydown: (event) => { + if (event.key === "Enter") void open(entry.id) + } + } + }, + statusDot(reviewOf(entry.id).status), + h("span", { class: "sound-name" }, entry.name), + flags(entry) + ) + ] + }) + ) + rows.scrollTop = scroll +} + +/** Only the list scrolls to the asset: on a phone the page around it stays put. */ +const scrollToRow = (id: string): void => { + const row = rows.querySelector(`[data-asset="${CSS.escape(id)}"]`) + if (!(row instanceof HTMLElement)) return + if (row.offsetTop < rows.scrollTop) rows.scrollTop = row.offsetTop - 24 + else if (row.offsetTop + row.offsetHeight > rows.scrollTop + rows.clientHeight) rows.scrollTop = row.offsetTop + row.offsetHeight - rows.clientHeight +} + +const step = (by: number): void => { + const entries = visible() + if (entries.length === 0) return + const at = entries.findIndex((entry) => entry.id === state.current) + void open(entries[(at + by + entries.length) % entries.length]!.id) +} + +const pill = (text: string, tone = ""): HTMLElement => h("span", { class: `stage-pill ${tone}` }, text) + +const head = h("div", { class: "sound-head" }) + +const renderHead = (): void => { + const entry = entryOf(state.current) + if (state.view !== "asset" || entry === undefined) return replace(head) + const said = reviewOf(entry.id) + const changed = changedNow(entry) + const frame = entry.frames[0] + replace( + head, + h( + "div", + { class: "sound-title" }, + h("h1", { title: entry.id }, entry.name), + pill(statusLabel[said.status], said.status), + changed !== undefined && h("span", { class: "stage-pill changes", title: changed }, "Changed since"), + entry.problem === undefined && + (entry.kind === "sheet" + ? pill(`${entry.animations.length} animation${entry.animations.length === 1 ? "" : "s"} · ${entry.frames.length} frames · ${frame?.w ?? 0}×${frame?.h ?? 0}`) + : pill(`${entry.width}×${entry.height}`)) + ), + h("p", { class: "sound-about pixel-path" }, entry.folder === "." ? entry.id : `${entry.folder}/`, entry.source !== undefined && h("span", { class: "pixel-source" }, ` · made from ${entry.source}`)) + ) +} + +const renderStage = (): void => { + screen = undefined + if (state.view === "set") return renderSet() + const entry = entryOf(state.current) + if (entry === undefined) { + replace(content, h("div", { class: "empty-card lab-empty" }, h("div", { class: "empty-icon" }, mark()), h("h2", {}, state.loaded ? "No art here" : "Reading the art…"))) + renderPlayback() + return + } + renderHead() + if (entry.problem !== undefined) { + replace(content, head, callout("bad", `This asset cannot be read: ${entry.problem}`), hint("Fix it, and it shows here as soon as it reads. The rest of the review goes on.")) + renderPlayback() + return + } + const size = screenOf(entry) + const canvas = h("canvas", { + class: "pixel-canvas", + title: "Press on a pixel, or drag across an area, to comment on it (it pauses there)", + attrs: { "aria-label": `${entry.name} at ${state.zoom}×`, style: `width:${size.w}px;height:${size.h}px` } + }) + const rings = h("div", { class: "pixel-rings", attrs: { "aria-hidden": "true" } }) + const counter = h("span", { class: "frame-counter pixel-counter" }) + screen = { entry, canvas, rings, counter, image: undefined, global: -1, place: 0 } + attachPinning(canvas, entry) + const holder = screen + void pictureOf(entry).then((image) => { + if (screen !== holder) return + if (image === undefined) return say(`${entry.name}: the picture did not load. It may have changed: it shows again on the next look.`) + holder.image = image + repaint() + }) + const animations = animationsOf(entry) + replace( + content, + head, + entry.animations.length > 1 && + h( + "div", + { class: "pixel-animations", attrs: { role: "group", "aria-label": "Animation" } }, + animations.map((animation) => + h( + "button", + { + class: `pixel-animation${animation.name === animationOf(entry).name ? " on" : ""}`, + attrs: { type: "button", "aria-pressed": String(animation.name === animationOf(entry).name) }, + on: { + click: () => { + state.animation = animation.name + state.frame = 0 + clock = performance.now() + state.composing = undefined + state.marks = undefined + renderStage() + renderComments() + } + } + }, + animation.name, + h("span", { class: "meta" }, String(animation.frames.length)) + ) + ) + ), + h("div", { class: "pixel-screen" }, h("div", { class: "pixel-frame", attrs: { style: `width:${size.w}px;height:${size.h}px` } }, canvas, rings)) + ) + renderPlayback() +} + +// --------------------------------------------------------------------------- +// The whole set side by side + +const renderSet = (): void => { + renderPlayback() + const zoom = state.setZoom + const entries = visible().filter((entry) => !state.onlyPicked || state.picked.has(entry.id)) + const [a] = backdropColors() + const folders = new Set(entries.map((entry) => entry.folder)) + const byFolder = new Map>() + for (const entry of entries) byFolder.set(entry.folder, [...(byFolder.get(entry.folder) ?? []), entry]) + const picked = [...state.picked].filter((id) => entryOf(id) !== undefined) + const tile = (entry: Entry): HTMLElement => { + const frame = entry.frames[animationsOf(entry)[0]!.frames[0] ?? 0] + const isPicked = state.picked.has(entry.id) + const art = + entry.problem !== undefined || frame === undefined + ? h("div", { class: "pixel-set-broken", title: entry.problem }, icon("alert")) + : h( + "div", + { class: "pixel-set-art", attrs: { style: `width:${frame.w * zoom}px;height:${frame.h * zoom}px` } }, + h("img", { + attrs: { + src: store.picture(entry.id, entry.version), + alt: entry.name, + loading: "lazy", + decoding: "async", + width: entry.width * zoom, + height: entry.height * zoom, + style: `width:${entry.width * zoom}px;height:${entry.height * zoom}px;margin-left:${-frame.x * zoom}px;margin-top:${-frame.y * zoom}px` + } + }) + ) + return h( + "div", + { class: `pixel-set-tile${entry.id === state.current ? " on" : ""}${isPicked ? " picked" : ""}`, title: entry.id, attrs: { "data-asset": entry.id } }, + h( + "button", + { + class: "pixel-set-open", + title: `Open ${entry.name}`, + attrs: { type: "button" }, + on: { click: () => void open(entry.id) } + }, + art + ), + h( + "button", + { + class: `pixel-pick${isPicked ? " on" : ""}`, + title: isPicked ? "Unpick" : "Pick, to compare or approve with others", + attrs: { type: "button", "aria-pressed": String(isPicked), "aria-label": `Pick ${entry.name}` }, + on: { + click: () => { + if (isPicked) state.picked.delete(entry.id) + else state.picked.add(entry.id) + if (state.picked.size === 0) state.onlyPicked = false + renderStage() + } + } + }, + icon("check") + ), + h("div", { class: "pixel-set-foot" }, statusDot(reviewOf(entry.id).status), h("span", { class: "pixel-set-name" }, entry.name), flags(entry)) + ) + } + replace( + content, + h( + "div", + { class: "sound-head" }, + h("div", { class: "sound-title" }, h("h1", {}, "The whole set"), pill(`${entries.length} shown`), pill(`${zoom}×`)), + h("p", { class: "sound-about" }, "Every asset at its own size, side by side, over one backdrop: does it read as one game? Pick some to compare them alone or approve them together. A sheet shows its first frame.") + ), + picked.length > 0 && + h( + "div", + { class: "pixel-picked" }, + h("span", {}, `${picked.length} picked`), + button(state.onlyPicked ? "Show all" : "Compare them", () => { + state.onlyPicked = !state.onlyPicked + renderStage() + }, { small: true, variant: "secondary", title: "Only the picked ones, side by side" }), + button("Approve them", () => { + for (const id of picked) { + const entry = entryOf(id) + if (entry !== undefined && entry.problem === undefined) setStatus(entry, "approved") + } + reviewChanged() + say(`Approved ${picked.length}: they look right as they are now.`) + }, { small: true, variant: "primary", icon: "check" }), + button("Clear", () => { + state.picked.clear() + state.onlyPicked = false + renderStage() + }, { small: true, variant: "ghost" }) + ), + entries.length === 0 && h("p", { class: "hint" }, "Nothing here."), + [...byFolder.entries()].map(([folder, inside]) => + h("section", { class: "pixel-set-folder" }, folders.size > 1 && h("h3", { class: "pixel-folder" }, folder), h("div", { class: "pixel-set", attrs: { style: `--backdrop:${a}` } }, inside.map(tile))) + ) + ) +} + +// --------------------------------------------------------------------------- +// The playback bar: frames, and the zoom + +const setZoom = (zoom: number): void => { + if (state.view === "set") { + state.setZoom = Math.max(1, Math.min(8, zoom)) + return renderStage() + } + const entry = entryOf(state.current) + state.zoom = Math.max(1, Math.min(entry === undefined ? 32 : maxZoom(entry), zoom)) + renderStage() +} + +const toggle = (): void => { + const entry = entryOf(state.current) + if (entry === undefined || entry.animations.length === 0 || state.view !== "asset") return + if (state.playing) state.frame = screen?.place ?? 0 + else clock = performance.now() + state.playing = !state.playing + renderPlayback() +} + +const stepFrame = (by: number): void => { + const entry = entryOf(state.current) + if (entry === undefined || entry.animations.length === 0) return + if (state.playing) { + state.frame = screen?.place ?? 0 + state.playing = false + } + state.frame += by + renderPlayback() + repaint() +} + +const renderPlayback = (): void => { + const entry = entryOf(state.current) + const sheet = state.view === "asset" && entry !== undefined && entry.problem === undefined && entry.animations.length > 0 + const zoom = state.view === "set" ? state.setZoom : state.zoom + const top = state.view === "set" ? 8 : entry === undefined ? 32 : maxZoom(entry) + replace( + playback, + sheet && [ + button(state.playing ? "Pause" : "Play", () => toggle(), { variant: "ghost", icon: state.playing ? "pause" : "play", iconOnly: true, title: state.playing ? "Pause (Space)" : "Play (Space)" }), + button("Previous frame", () => stepFrame(-1), { variant: "ghost", icon: "previous", iconOnly: true, title: "Previous frame (←)" }), + button("Next frame", () => stepFrame(1), { variant: "ghost", icon: "next", iconOnly: true, title: "Next frame (→)" }), + screen?.counter, + h("span", { class: "divider" }) + ], + state.view === "asset" && entry !== undefined && entry.animations.length === 0 && screen?.counter, + state.view === "asset" && entry !== undefined && entry.animations.length === 0 && h("span", { class: "divider" }), + button("Zoom out", () => setZoom(zoom - 1), { variant: "ghost", icon: "minus", iconOnly: true, disabled: zoom <= 1, title: "Zoom out (−)" }), + h("span", { class: "zoom-value", attrs: { "aria-label": "Zoom" } }, `${zoom}×`), + button("Zoom in", () => setZoom(zoom + 1), { variant: "ghost", icon: "plus", iconOnly: true, disabled: zoom >= top, title: "Zoom in (+)" }), + zoom !== 1 && button("1×", () => setZoom(1), { variant: "ghost", small: true, title: "Back to its own size (0)" }) + ) + renderCounter() +} + +// --------------------------------------------------------------------------- +// Panels + +const statusOptions: ReadonlyArray<{ readonly id: Status; readonly label: string; readonly icon: "sparkle" | "check" | "alert"; readonly title: string }> = [ + { id: "new", label: "To review", icon: "sparkle", title: "Not reviewed yet" }, + { id: "approved", label: "Approved", icon: "check", title: "It looks right, as it is now" }, + { id: "changes", label: "Changes", icon: "alert", title: "Ask the agent for changes: say what in the note and pin comments to the pixels" } +] + +/** Redraws a box, keeping the text being typed in it focused, with its caret. */ +const redraw = (box: HTMLElement, ...children: Array): void => { + const typing = document.activeElement instanceof HTMLTextAreaElement && box.contains(document.activeElement) ? document.activeElement : undefined + const label = typing?.getAttribute("aria-label") + const caret = typing?.selectionStart ?? 0 + replace(box, ...children) + if (label == null) return + const again = box.querySelector(`textarea[aria-label="${label}"]`) + if (again instanceof HTMLTextAreaElement) { + again.focus() + again.setSelectionRange(caret, caret) + } +} + +const panelsShown = (): boolean => state.loaded && state.view === "asset" && entryOf(state.current) !== undefined + +const renderReview = (): void => { + const entry = entryOf(state.current) + if (!panelsShown() || entry === undefined) return redraw(reviewBox, state.view === "set" && setPanel()) + const id = entry.id + const said = reviewOf(id) + const changed = changedNow(entry) + redraw( + reviewBox, + section( + { + id: "pixel-review", + title: "Review", + help: "Approve what looks right as it is, or ask for changes: say what in the note, and pin comments to the pixels they are about. The agent reads it all with `ritmo-tools pixel notes`. Approving here is not the last word: the art still has to look right in the game, at the size it is drawn.", + onToggle: renderReview + }, + segmented( + said.status, + statusOptions, + (status) => { + if (status === reviewOf(id).status) return + setStatus(entry, status) + reviewChanged() + }, + { label: "Status", class: "fill" } + ), + changed !== undefined && + callout( + "warn", + `Changed since you approved it: ${changed}.`, + h( + "div", + { class: "approve-again" }, + button("Approve it as it is now", () => { + setReview(id, { approved: approvalOf(entry.frames) }) + reviewChanged() + }) + ) + ), + state.reviewProblem !== undefined && callout("bad", `The review file cannot be read, so nothing here is saved: ${state.reviewProblem}`), + h( + "label", + { class: "input note" }, + h("textarea", { + props: { value: said.note, rows: 3 }, + attrs: { placeholder: said.status === "changes" ? "What should change? Pin comments to the pixels too." : "A note for the agent (optional)", "aria-label": "Note" }, + on: { + input: (event) => { + const target = event.currentTarget + if (!(target instanceof HTMLTextAreaElement)) return + // The note is this asset's, whatever is open when it saves. + setReview(id, { note: target.value }) + save() + history.schedule() + renderTopbar() + } + } + }) + ) + ) + ) +} + +/** Shows a comment's frame, paused, with its area ringed. */ +const goTo = (entry: Entry, comment: Comment): void => { + if (comment.animation !== undefined && entry.animations.some((animation) => animation.name === comment.animation)) state.animation = comment.animation + state.playing = false + state.frame = comment.frame + state.marks = undefined + if (state.view !== "asset") state.view = "asset" + renderStage() + repaint() +} + +const areaWords = (area: Area): string => whereOf({ ...area, text: "" }) + +const renderComments = (): void => { + const entry = entryOf(state.current) + if (!panelsShown() || entry === undefined) return redraw(commentsBox) + const id = entry.id + const comments = reviewOf(id).comments + const open = comments.filter((comment) => comment.resolved !== true).length + const composing = state.composing + const setComment = (index: number, change: Partial): void => { + setReview(id, { comments: reviewOf(id).comments.map((comment, i) => (i === index ? { ...comment, ...change } : comment)) }) + reviewChanged() + } + const add = (): void => { + const text = commentsBox.querySelector(".pixel-compose textarea")?.value.trim() ?? "" + if (text === "" || state.composing === undefined) return + const area = state.composing + setReview(id, { + comments: [ + ...reviewOf(id).comments, + { ...(area.animation === undefined ? {} : { animation: area.animation }), frame: area.frame, x: area.x, y: area.y, ...(area.width > 1 ? { width: area.width } : {}), ...(area.height > 1 ? { height: area.height } : {}), text } + ] + }) + state.composing = undefined + reviewChanged() + } + redraw( + commentsBox, + section( + { + id: "pixel-comments", + title: "Comments", + help: "Press on a pixel, or drag across an area, to pin a comment to it on the frame shown (a sheet pauses there). The agent sees each comment numbered, with its frame and pixels, and resolves it with a reply once it has acted.", + onToggle: renderComments, + action: h("span", { class: "meta" }, comments.length === 0 ? "" : `${open} open`) + }, + composing !== undefined && + h( + "div", + { class: "fx-compose pixel-compose" }, + h("p", { class: "hint" }, `On ${areaWords(composing)}`), + h( + "label", + { class: "input note" }, + h("textarea", { + props: { rows: 2 }, + attrs: { placeholder: "What should change here? (\"outline too thick\", \"reads as a smudge at 1×\")", "aria-label": "Comment" }, + on: { + keydown: (event) => { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) add() + if (event.key === "Escape") { + state.composing = undefined + renderComments() + renderRings() + } + } + } + }) + ), + h( + "div", + { class: "fx-compose-actions" }, + button("Cancel", () => { + state.composing = undefined + renderComments() + renderRings() + }, { small: true, variant: "ghost" }), + button("Add comment", add, { small: true, variant: "primary", icon: "comment" }) + ) + ), + comments.length === 0 && composing === undefined + ? hint(entry.problem === undefined ? "Press on a pixel, or drag across an area, to pin a comment to it: the agent sees the spot ringed." : "This asset cannot be drawn, so there is nothing to pin a comment to: say it in the note.") + : h( + "ol", + { class: "fx-comments" }, + comments.map((comment, index) => + h( + "li", + { class: comment.resolved === true ? "resolved" : "", attrs: { "data-comment": index + 1 } }, + h( + "button", + { class: "fx-comment", title: "Show this frame, with the area ringed", attrs: { type: "button" }, on: { click: () => goTo(entry, comment) } }, + h("span", { class: "fx-comment-number" }, comment.resolved === true ? icon("check") : String(index + 1)), + h("span", { class: "fx-comment-where" }, `${whereOf(comment)}${comment.resolved === true ? " · resolved" : ""}`), + h("span", { class: "fx-comment-text" }, comment.text), + comment.resolved === true && comment.reply !== undefined && h("span", { class: "fx-comment-reply" }, `Agent: ${comment.reply}`) + ), + comment.resolved === true && button("Reopen", () => setComment(index, { resolved: undefined, reply: undefined }), { small: true, variant: "ghost", title: "It is not done: the agent sees it again in `pixel notes`" }), + button("Delete", () => { + setReview(id, { comments: reviewOf(id).comments.filter((_, i) => i !== index) }) + reviewChanged() + }, { small: true, variant: "ghost", icon: "trash", iconOnly: true, title: "Delete this comment" }) + ) + ) + ) + ) + ) +} + +/** Where a lint issue is, in words: its frame on a sheet. */ +const issueFrame = (entry: Entry, issue: FrameIssue): string => { + if (issue.frame === undefined) return "" + const frame = entry.frames[issue.frame] + return frame === undefined ? "" : `${frame.animation ?? ""} frame ${frame.index}: ` +} + +const renderChecks = (): void => { + const entry = entryOf(state.current) + if (!panelsShown() || entry === undefined) return redraw(checksBox) + const detail = state.detail?.id === entry.id ? state.detail : undefined + const issues = detail?.issues ?? [] + redraw( + checksBox, + section( + { + id: "pixel-checks", + title: "Rules", + help: "The lint against this asset's nearest pixel.config.json, the rules the agent checks with `ritmo-tools pixel lint`: its size, palette, colors, transparency, and stray pixels, each frame of a sheet apart. Warnings to weigh, not a verdict: you judge how it looks.", + onToggle: renderChecks, + action: h("span", { class: "meta" }, entry.problem !== undefined ? "" : entry.errors + entry.warnings === 0 ? "clean" : `${entry.errors} errors · ${entry.warnings} warnings`) + }, + entry.lintProblem !== undefined && callout("warn", `Cannot lint it: ${entry.lintProblem}`), + entry.problem === undefined && + entry.lintProblem === undefined && + (detail === undefined + ? hint("Reading its lint…") + : issues.length === 0 + ? callout("good", detail.config === undefined ? "No rules broken (no pixel.config.json above it: only the general ones)." : "No rules broken.") + : h( + "ul", + { class: "issues" }, + issues.map((issue) => + h( + "li", + { class: issue.severity }, + icon(issue.severity === "error" ? "alert" : "info"), + h("span", {}, `${issueFrame(entry, issue)}${issue.message}`), + issue.at.length > 0 && + button("Show", () => { + const frame = issue.frame === undefined ? undefined : entry.frames[issue.frame] + if (frame?.animation !== undefined) state.animation = frame.animation + state.playing = false + state.frame = frame?.index ?? 0 + state.marks = { frame: issue.frame ?? 0, at: issue.at } + state.composing = undefined + renderStage() + repaint() + }, { small: true, variant: "ghost", title: "Pause on its frame with its pixels ringed" }) + ) + ) + )), + detail !== undefined && + group( + "Files", + prop("Asset", h("code", { title: detail.file }, entry.id)), + detail.source !== undefined && prop("Made from", h("code", {}, detail.source), "The file the agent changes to make this one again"), + detail.sheet !== undefined && prop("Frames", h("code", {}, detail.sheet)), + prop("Rules", h("code", {}, detail.config ?? "none")) + ) + ) + ) +} + +const renderViewPanel = (): void => { + if (!state.loaded) return redraw(viewBox) + const swatch = (id: Backdrop, label: string, title: string): HTMLElement => { + const [a, b] = id === "custom" ? [state.custom, state.custom] : BUILT_IN[id] + return h( + "button", + { + class: `fx-backdrop${state.backdrop === id ? " on" : ""}`, + title, + attrs: { type: "button", "aria-pressed": String(state.backdrop === id) }, + on: { + click: () => { + state.backdrop = id + viewChanged() + } + } + }, + h("span", { class: "fx-backdrop-swatch", attrs: { style: `background:linear-gradient(135deg, ${a} 50%, ${b} 50%)` } }), + label + ) + } + redraw( + viewBox, + section( + { id: "pixel-view", title: "View", help: "What the art is drawn over, and how. The backdrop is in squares of 4 art pixels, so the pixel size shows; pick one near the game's own ground. Repeat draws the frame three by three, to see a tile's seams.", onToggle: renderViewPanel }, + group( + "Backdrop", + h( + "div", + { class: "fx-backdrops" }, + swatch("dark", "Dark", "A dark night blue"), + swatch("light", "Light", "A light paper"), + swatch("checker", "Checker", "Grey squares, to see what is transparent"), + swatch("custom", "Yours", "The color below: the game's own ground"), + h("input", { + class: "pixel-color", + props: { type: "color", value: state.custom }, + attrs: { "aria-label": "Your backdrop color", title: "Your backdrop color" }, + on: { + input: (event) => { + const target = event.currentTarget + if (!(target instanceof HTMLInputElement)) return + state.custom = target.value + state.backdrop = "custom" + remember() + repaint() + if (state.view === "set") renderStage() + }, + change: () => renderViewPanel() + } + }) + ) + ), + state.view === "asset" && + switchRow("Repeat", state.repeat, (on) => { + state.repeat = on + viewChanged() + }, "Draw the frame three by three, to see whether a tile repeats without seams") + ) + ) +} + +const viewChanged = (): void => { + remember() + renderViewPanel() + renderStage() +} + +const setPanel = (): HTMLElement => + section( + { id: "pixel-set", title: "The whole set", help: "Every asset at its own size, side by side, to judge the art as one game: its sizes, outlines, palette, and detail should agree. Pick some (the check under each) to compare them alone or approve them together; open one to comment on it.", onToggle: renderReview }, + hint(`${visible().length} shown at ${state.setZoom}×. Open one to comment on its pixels.`) + ) + +const renderAbout = (): void => + redraw( + aboutBox, + h( + "footer", + { class: "about" }, + h("div", { class: "ruler", attrs: { "aria-hidden": "true" } }), + h("div", { class: "about-head" }, mark(), "Pixel review, by Ritmo"), + h( + "p", + {}, + "A game's pixel art, reviewed by a person and read back by the agent: ", + h("code", {}, "ritmo-tools pixel notes"), + ", ", + h("code", {}, "ritmo-tools pixel resolve"), + ". What you say is saved to ", + h("code", {}, "pixel.review.json"), + " at the game's root. Fixing pixels by hand belongs on the pixel fixer's page." + ), + h("p", { class: "hint" }, "Keys: ↑ ↓ change asset, Space plays, ← → step frames, + − zoom, 0 back to 1×, Tab the whole set, Esc drops a comment being drawn.") + ) + ) + +// --------------------------------------------------------------------------- +// Messages, and the person's way of working + +let toastTimer: ReturnType | undefined + +function say(message: string): void { + if (toastTimer !== undefined) clearTimeout(toastTimer) + replace(toast, h("span", {}, message), button("Dismiss", () => toast.classList.remove("on"), { variant: "ghost", icon: "close", iconOnly: true, small: true })) + toast.classList.add("on") + toastTimer = setTimeout(() => toast.classList.remove("on"), 6000) +} + +const VIEW_KEY = "ritmo-pixel-review-view" + +/** Remembers how the person looks at art (backdrop, repeat): a convenience only. */ +const remember = (): void => { + try { + localStorage.setItem(VIEW_KEY, JSON.stringify({ backdrop: state.backdrop, custom: state.custom, repeat: state.repeat })) + } catch { + // Storage may be off; the page works without it. + } +} + +const recall = (): void => { + try { + const raw: unknown = JSON.parse(localStorage.getItem(VIEW_KEY) ?? "{}") + if (typeof raw !== "object" || raw === null) return + if ("backdrop" in raw && (raw.backdrop === "dark" || raw.backdrop === "light" || raw.backdrop === "checker" || raw.backdrop === "custom")) state.backdrop = raw.backdrop + if ("custom" in raw && typeof raw.custom === "string" && parseHex(raw.custom) !== undefined) state.custom = raw.custom + if ("repeat" in raw && typeof raw.repeat === "boolean") state.repeat = raw.repeat + } catch { + // Nothing remembered. + } +} + +function update(): void { + renderTopbar() + renderRail() + renderStage() + renderReview() + renderComments() + renderChecks() + renderViewPanel() + renderAbout() +} + +// The arrows move through the list and the frames; Space plays; + and − zoom; Ctrl/Cmd+Z undoes. +window.addEventListener("keydown", (event) => { + const target = event.target + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) { + if (event.key === "Escape" && target instanceof HTMLTextAreaElement) target.blur() + return + } + const mod = event.metaKey || event.ctrlKey + if (mod && event.key.toLowerCase() === "z") { + event.preventDefault() + if (event.shiftKey) history.redo() + else history.undo() + } else if (mod && event.key.toLowerCase() === "y") { + event.preventDefault() + history.redo() + } else if (mod || event.altKey) { + return + } else if (event.key === " ") { + event.preventDefault() + toggle() + } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault() + step(event.key === "ArrowDown" ? 1 : -1) + } else if ((event.key === "ArrowLeft" || event.key === "ArrowRight") && state.view === "asset") { + event.preventDefault() + stepFrame(event.key === "ArrowRight" ? 1 : -1) + } else if (event.key === "+" || event.key === "=") { + setZoom((state.view === "set" ? state.setZoom : state.zoom) + 1) + } else if (event.key === "-" || event.key === "_") { + setZoom((state.view === "set" ? state.setZoom : state.zoom) - 1) + } else if (event.key === "0") { + setZoom(1) + } else if (event.key === "Tab" && !event.shiftKey && target === document.body) { + event.preventDefault() + setView(state.view === "asset" ? "set" : "asset") + } else if (event.key === "Escape" && (state.composing !== undefined || state.marks !== undefined)) { + state.composing = undefined + state.marks = undefined + renderRings() + renderComments() + } +}) + +window.addEventListener("resize", () => repaint()) + +/** Draws the page and opens the art from where `chosen` keeps it; follows the agent's changes. */ +export const start = (chosen: Store): void => { + store = chosen + recall() + update() + void load() + requestAnimationFrame(tick) + setInterval(() => void follow(), 1500) +} diff --git a/packages/tools/src/pixel/review/main.ts b/packages/tools/src/pixel/review/main.ts new file mode 100644 index 00000000..e454ef41 --- /dev/null +++ b/packages/tools/src/pixel/review/main.ts @@ -0,0 +1,5 @@ +/** The review page as `ritmo-tools pixel review ` serves it: the art and `pixel.review.json` are the files the agent reads. */ +import { start } from "./lab.ts" +import { serverStore } from "./store.ts" + +start(serverStore) diff --git a/packages/tools/src/pixel/review/store.ts b/packages/tools/src/pixel/review/store.ts new file mode 100644 index 00000000..6d31bac4 --- /dev/null +++ b/packages/tools/src/pixel/review/store.ts @@ -0,0 +1,126 @@ +/** + * Where the review page reads the art and reads and writes the review: the + * API `ritmo-tools pixel review` serves beside the page (`cli/serve.ts`). + * The server reads, hashes, and lints every asset; the page only draws. + */ +import type { Issue } from "../core/lint.ts" +import type { Frame } from "../core/review.ts" +import type { SheetAnimationRead } from "../core/spritesheet.ts" + +/** One asset on the page's list, with what the page needs to draw its row, play it, and approve it. */ +export interface Entry { + readonly id: string + /** Its file's name. */ + readonly name: string + /** The folder it is in, from where the review was started. */ + readonly folder: string + readonly kind: "still" | "sheet" + /** The picture's size (a sheet's, with every frame). */ + readonly width: number + readonly height: number + /** Each frame with where it is on the picture, how long it shows, and its hash: what an approval keeps. */ + readonly frames: ReadonlyArray + /** A sheet's animations, each its frames (indices into `frames`) as listed and in play order; none for a still. */ + readonly animations: ReadonlyArray + /** What it is made from, when that sits beside it. */ + readonly source: string | undefined + /** The lint's counts; the issues come with the detail. */ + readonly errors: number + readonly warnings: number + /** Why it cannot be linted (a broken config), when it cannot. */ + readonly lintProblem: string | undefined + /** Why it cannot be read, when it cannot. */ + readonly problem: string | undefined + /** Changes when its files do, so the page can follow the agent's edits. */ + readonly version: string +} + +/** A lint issue on one frame of a sheet (its index in `frames`), or on the whole of a still. */ +export interface FrameIssue extends Issue { + readonly frame?: number | undefined +} + +/** What the page shows beside the asset open: its lint issues, and the files it comes from. */ +export interface Detail { + readonly id: string + readonly file: string + readonly source: string | undefined + readonly sheet: string | undefined + readonly config: string | undefined + readonly issues: ReadonlyArray + readonly version: string +} + +export interface Listed { + /** Changes when any asset does. */ + readonly version: string + /** The review's version: changes when its file does. */ + readonly review: string + /** Every asset, unless nothing changed since the version asked about. */ + readonly entries?: ReadonlyArray | undefined +} + +/** What a save says: written, with the review's new version, or written meanwhile by someone else, with what the file holds now. */ +export type Saved = { readonly ok: true; readonly version: string } | { readonly ok: false; readonly text: string; readonly version: string } + +export interface Store { + /** The list, or `undefined` when it cannot be read (the CLI was stopped). */ + readonly library: (since: string) => Promise + /** The whole review, by asset id; fails when the review file is broken. */ + readonly review: () => Promise<{ readonly text: string; readonly version: string }> + /** Writes the whole review, when the file is still at `base`; fails with `SERVER_GONE` when the server cannot be reached. */ + readonly save: (text: string, base: string) => Promise + readonly detail: (id: string) => Promise + /** Where the asset's picture is, as PNG, at this version. */ + readonly picture: (id: string, version: string) => string +} + +/** What a save failure says when the server cannot be reached at all. */ +export const SERVER_GONE = "the review server is not running. Start `ritmo-tools pixel review` again; the page saves what is owed once it is back" + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null + +const failure = (answer: unknown, fallback: string): string => (isRecord(answer) && typeof answer.error === "string" ? answer.error : fallback) + +const isDetail = (value: unknown): value is Detail => isRecord(value) && typeof value.id === "string" && typeof value.version === "string" && Array.isArray(value.issues) + +const query = (id: string): string => `id=${encodeURIComponent(id)}` + +/** The API `ritmo-tools pixel review` serves beside the page. */ +export const serverStore: Store = { + library: async (since) => { + try { + const answer: unknown = await (await fetch(`/api/library?since=${encodeURIComponent(since)}`)).json() + return isRecord(answer) && typeof answer.version === "string" && typeof answer.review === "string" + ? { version: answer.version, review: answer.review, entries: Array.isArray(answer.entries) ? answer.entries : undefined } + : undefined + } catch { + return undefined + } + }, + review: async () => { + const response = await fetch("/api/review") + const answer: unknown = await response.json() + if (!response.ok || !isRecord(answer) || typeof answer.text !== "string" || typeof answer.version !== "string") throw new Error(failure(answer, response.statusText)) + return { text: answer.text, version: answer.version } + }, + save: async (text, base) => { + const response = await fetch(`/api/review?base=${encodeURIComponent(base)}`, { method: "PUT", body: text }).catch(() => { + throw new Error(SERVER_GONE) + }) + const answer: unknown = await response.json() + if (response.status === 409 && isRecord(answer) && typeof answer.text === "string" && typeof answer.version === "string") return { ok: false, text: answer.text, version: answer.version } + if (!response.ok || !isRecord(answer) || typeof answer.version !== "string") throw new Error(failure(answer, response.statusText)) + return { ok: true, version: answer.version } + }, + detail: async (id) => { + try { + const response = await fetch(`/api/asset?${query(id)}`) + const answer: unknown = await response.json() + return response.ok && isDetail(answer) ? answer : undefined + } catch { + return undefined + } + }, + picture: (id, version) => `/api/png?${query(id)}&v=${encodeURIComponent(version)}` +} diff --git a/packages/tools/src/pixel/review/style.css b/packages/tools/src/pixel/review/style.css new file mode 100644 index 00000000..719b6626 --- /dev/null +++ b/packages/tools/src/pixel/review/style.css @@ -0,0 +1,327 @@ +/* + * The pixel review's own pieces, over the pixel fixer's, the sound lab's, + * and the effects lab's styles: the list of assets by folder, one asset at + * a whole-number zoom with its comments ringed, and the whole set side by + * side at its own size. + */ + +.pixel-folder { + margin: 10px 8px 2px; + overflow: hidden; + color: var(--muted); + font: 500 11px var(--mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.pixel-rows .pixel-folder:first-child { + margin-top: 2px; +} + +.sound-flag.lint .icon { + width: 12px; + height: 12px; +} + +.pixel-path { + font-family: var(--mono); + font-size: 12px; + overflow-wrap: anywhere; +} + +.pixel-source { + color: #8a8f98; +} + +/* One asset: its animations, then the canvas, which scrolls inside its own box when it is wider than the stage. */ + +.pixel-animations { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; +} + +.pixel-animation { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + border: 1px solid rgba(243, 241, 236, 0.12); + border-radius: 999px; + background: rgba(243, 241, 236, 0.04); + color: #bdbab3; + font: 500 12px var(--mono); + cursor: pointer; +} + +.pixel-animation:hover { + background: rgba(243, 241, 236, 0.08); +} + +.pixel-animation.on { + border-color: var(--accent); + color: #f3f1ec; +} + +.pixel-animation .meta { + color: #80858e; +} + +.pixel-screen { + max-width: 100%; + overflow: auto; + overscroll-behavior: contain; +} + +.pixel-frame { + position: relative; + margin: 0 auto; +} + +.pixel-canvas { + display: block; + image-rendering: pixelated; + cursor: crosshair; + touch-action: none; +} + +.pixel-rings { + position: absolute; + inset: 0; + pointer-events: none; +} + +/* Magenta: the debug color, in almost no game's palette, so a ring shows on any art. */ +.pixel-ring { + position: absolute; + box-sizing: border-box; + border: 2px solid #ff2bd6; + border-radius: 2px; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55); +} + +.pixel-ring.drawing { + border-style: dashed; +} + +.pixel-ring.mark { + border-color: #fee761; +} + +.pixel-ring-label { + position: absolute; + left: 100%; + bottom: 100%; + display: inline-grid; + place-items: center; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 999px; + background: #ff2bd6; + color: #ffffff; + font: 600 10.5px/1 var(--sans, Inter, system-ui, sans-serif); + transform: translate(-6px, 6px); +} + +.pixel-counter { + min-width: 0; + padding: 0 6px; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.pixel-stage .playback { + flex-wrap: wrap; + justify-content: center; + width: max-content; + max-width: calc(100% - 24px); +} + +.pixel-color { + width: 34px; + height: var(--control); + padding: 2px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + cursor: pointer; +} + +.topbar-center .pixel-views { + margin-left: 8px; +} + +/* The whole set: every asset at its own size, over one backdrop. */ + +.pixel-set-folder + .pixel-set-folder { + margin-top: 14px; +} + +.pixel-set-folder .pixel-folder { + margin: 0 0 6px; +} + +/* One backdrop under the whole set, so the art reads together, as on one screen. */ +.pixel-set { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 12px; + padding: 12px; + border-radius: var(--radius-lg); + background: var(--backdrop, #1a1c2c); +} + +.pixel-set-tile { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + max-width: 100%; + padding: 3px; + border-radius: 4px; +} + +.pixel-set-tile.on { + box-shadow: 0 0 0 1px rgba(243, 241, 236, 0.45); +} + +.pixel-set-tile.picked { + box-shadow: 0 0 0 2px var(--accent); +} + +.pixel-set-open { + display: grid; + place-items: center; + max-width: 100%; + overflow: auto; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; +} + +.pixel-set-art { + overflow: hidden; +} + +.pixel-set-art img { + display: block; + max-width: none; + image-rendering: pixelated; +} + +.pixel-set-broken { + display: grid; + place-items: center; + width: 24px; + height: 24px; + color: var(--bad); +} + +.pixel-set-foot { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + max-width: 112px; + padding: 1px 4px; + border-radius: 4px; + background: rgba(15, 16, 20, 0.6); + font-size: 10.5px; +} + +.pixel-set-name { + min-width: 0; + overflow: hidden; + color: #d4d4da; + font-family: var(--mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.pixel-set-foot .dot { + width: 7px; + height: 7px; +} + +.pixel-set-foot .sound-flag .icon { + width: 11px; + height: 11px; +} + +/* Picking: a check in the tile's corner, shown on hover, when picked, and always where there is no hover. */ +.pixel-pick { + position: absolute; + top: -6px; + left: -6px; + display: inline-grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + border: 1.5px solid rgba(243, 241, 236, 0.5); + border-radius: 4px; + background: rgba(15, 16, 20, 0.85); + color: transparent; + cursor: pointer; + opacity: 0; +} + +.pixel-set-tile:hover .pixel-pick, +.pixel-pick:focus-visible, +.pixel-pick.on { + opacity: 1; +} + +@media (hover: none) { + .pixel-pick { + opacity: 1; + } +} + +.pixel-pick .icon { + width: 12px; + height: 12px; +} + +.pixel-pick.on { + border-color: var(--accent); + background: var(--accent); + color: #ffffff; +} + +.pixel-picked { + position: sticky; + top: 0; + z-index: 1; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 14px; + padding: 8px 10px; + border-radius: var(--radius-lg); + background: var(--floating); + box-shadow: var(--shadow-float); + color: #ececef; + font-size: 12.5px; +} + +.pixel-picked > span { + margin-right: auto; +} + +@media (max-width: 760px) { + .topbar-center .pixel-views { + margin-left: 0; + } + + .pixel-set-foot { + max-width: 96px; + } +} From 304ad128dd4819071f05105bce0eaae804ea755b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 22:36:03 +0000 Subject: [PATCH 5/9] ritmo-tools pixel: lint a picture of thousands of colors without running out of memory A game's folder holds more than pixel art: a smooth 512 px app icon has tens of thousands of colors, and comparing every pair of them for near-duplicates took gigabytes and never finished, so the review of a game with an icon in it crashed. Past 512 colors the lint now says the picture is not pixel art yet, and only the first few pairs are kept as text below that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HendNec6exyZNa2zBVD977 --- packages/tools/src/pixel/core/lint.ts | 23 +++++++++++++++++++---- packages/tools/test/agent.test.ts | 11 +++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/tools/src/pixel/core/lint.ts b/packages/tools/src/pixel/core/lint.ts index 0d4dc6ab..470402d3 100644 --- a/packages/tools/src/pixel/core/lint.ts +++ b/packages/tools/src/pixel/core/lint.ts @@ -48,6 +48,8 @@ export interface Issue { /** Colors closer than this (OKLab) look the same: in pixel art they are noise or anti-aliasing. */ const NEAR_DUPLICATE = 0.03 +/** Past this many colors a picture is not pixel art, and its pairs of colors are not compared one by one. */ +const NEAR_DUPLICATE_LIMIT = 512 const SHOWN = 20 /** The mean color change (OKLab) across a wrap below which no line shows. */ const SEAM_VISIBLE = 0.01 @@ -226,23 +228,36 @@ export const lintArt = (art: PixelArt, rules: LintRules = {}, partial = 0): Arra } } + if (used.length > NEAR_DUPLICATE_LIMIT) { + // A painting, a photo, or a smooth icon: comparing every pair of its colors would take minutes and gigabytes. + issues.push({ + rule: "near-duplicate-colors", + severity: "warning", + message: `${used.length} colors, many too close to tell apart: this is not pixel art yet (\`pixel fix\` or \`pixel pixelate\` makes it so)`, + count: used.length, + at: [] + }) + return issues + } const labs = used.map((index) => ({ index, lab: toOklab(art.palette[index]!) })) const pairs: Array = [] + let count = 0 for (let i = 0; i < labs.length; i++) { for (let j = i + 1; j < labs.length; j++) { const a = labs[i]! const b = labs[j]! if (Math.hypot(a.lab.l - b.lab.l, a.lab.a - b.lab.a, a.lab.b - b.lab.b) < NEAR_DUPLICATE) { - pairs.push(`${toHex(art.palette[a.index]!)}/${toHex(art.palette[b.index]!)}`) + count++ + if (pairs.length < 6) pairs.push(`${toHex(art.palette[a.index]!)}/${toHex(art.palette[b.index]!)}`) } } } - if (pairs.length > 0) { + if (count > 0) { issues.push({ rule: "near-duplicate-colors", severity: "warning", - message: `${pairs.length} pair${pairs.length === 1 ? "" : "s"} of colors too close to tell apart (${pairs.slice(0, 6).join(", ")}${pairs.length > 6 ? ", …" : ""}): merge them`, - count: pairs.length, + message: `${count} pair${count === 1 ? "" : "s"} of colors too close to tell apart (${pairs.join(", ")}${count > 6 ? ", …" : ""}): merge them`, + count, at: [] }) } diff --git a/packages/tools/test/agent.test.ts b/packages/tools/test/agent.test.ts index c47fa33e..38981550 100644 --- a/packages/tools/test/agent.test.ts +++ b/packages/tools/test/agent.test.ts @@ -138,6 +138,17 @@ describe("lint", () => { expect(lintArt(artOfImage(image).art, {}, partial).map((issue) => issue.rule)).toContain("partial-alpha") }) + it("says a picture of thousands of colors is not pixel art yet, without comparing every pair of them", () => { + // A smooth app icon: every pixel its own shade. + const size = 256 + const smooth = { width: size, height: size, data: new Uint8ClampedArray(size * size * 4) } + for (let i = 0; i < size * size; i++) smooth.data.set([i % 256, Math.floor(i / 256), 128, 255], i * 4) + const started = performance.now() + const near = lintArt(artOfImage(smooth).art, {}).filter((issue) => issue.rule === "near-duplicate-colors") + expect([near.length, near[0]?.message.startsWith("65536 colors")]).toEqual([1, true]) + expect(performance.now() - started).toBeLessThan(5000) + }) + it("lets a sprite touch its edges without calling it a background", () => { const parsed = parseSprite(coin) expect(parsed.ok && lintArt(parsed.art, { transparentBackground: true })).toEqual([]) From 168d242818dcc924acd4f823bced11871de7c628 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 22:36:03 +0000 Subject: [PATCH 6/9] ritmo-tools pixel review: what testing in a browser found A sheet frame with nothing drawn in it is an error on that frame, since it plays as a blink. The read kept for each asset holds its size, not its pixels. A broken asset still names the rules it would be linted against. Long file paths wrap in the side panel, and the playback bar stays one row on a phone. V, not Tab, switches between one asset and the whole set, since Tab moves through the page's controls. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HendNec6exyZNa2zBVD977 --- packages/tools/src/pixel/cli/library.ts | 24 ++++++++++++++++------- packages/tools/src/pixel/review/lab.ts | 17 ++++++++-------- packages/tools/src/pixel/review/style.css | 11 +++++++++++ packages/tools/test/pixel-review.test.ts | 10 ++++++++++ 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/packages/tools/src/pixel/cli/library.ts b/packages/tools/src/pixel/cli/library.ts index 2b6e968d..21c86197 100644 --- a/packages/tools/src/pixel/cli/library.ts +++ b/packages/tools/src/pixel/cli/library.ts @@ -116,7 +116,9 @@ export type Lint = export type AssetRead = | { readonly ok: true - readonly image: RgbaImage + /** Its size: the pixels are not kept, so a game's worth of reads stays small. */ + readonly width: number + readonly height: number /** The picture as the page draws it: decoded and written again, so it shows exactly the pixels hashed (a text sprite, rendered). */ readonly png: Uint8Array readonly frames: ReadonlyArray @@ -185,7 +187,13 @@ const lintOf = async (asset: PixelAsset, image: RgbaImage, frames: ReadonlyArray issues.push(...reportOfArt(read.art, loaded.rules, read.partial).issues) } else for (const [index, frame] of frames.entries()) { - const read = artOfImage(crop(image, frame)) + const cropped = crop(image, frame) + // A frame with nothing drawn shows as a blink when it plays: a box that missed its art. + if (!cropped.data.some((value, i) => i % 4 === 3 && value > 0)) { + issues.push({ rule: "empty-frame", severity: "error", message: "Nothing is drawn in this frame: its box missed the art, or the frame is left over", count: 0, at: [], frame: index }) + continue + } + const read = artOfImage(cropped) issues.push(...reportOfArt(read.art, loaded.rules, read.partial).issues.map((issue) => ({ ...issue, frame: index }))) } const count = (severity: Severity): number => issues.filter((issue) => issue.severity === severity).length @@ -200,7 +208,7 @@ const read = async (asset: PixelAsset): Promise => { if (!parsed.ok) return { ok: false, problem: parsed.problems.map((problem) => (problem.line === undefined ? problem.message : `line ${problem.line}: ${problem.message}`)).join("; ") } const image = toRgba(parsed.art) const frames: ReadonlyArray = [{ name: "", index: 0, x: 0, y: 0, w: image.width, h: image.height, hash: hashFrame(image, 0, 0, image.width, image.height) }] - return { ok: true, image, png: encodePng(image), frames, animations: [], sheet: undefined, lint: await lintOf(asset, image, frames, parsed.art) } + return { ok: true, width: image.width, height: image.height, png: encodePng(image), frames, animations: [], sheet: undefined, lint: await lintOf(asset, image, frames, parsed.art) } } const image = decodePicture(await readFile(asset.file)) const sheetFile = sheetFileOf(asset) @@ -216,7 +224,7 @@ const read = async (asset: PixelAsset): Promise => { const animation = animations.find((entry) => entry.frames.includes(i)) return { name: rect.name, animation: animation?.name ?? "frames", index: animation?.frames.indexOf(i) ?? i, x: rect.x, y: rect.y, w: rect.w, h: rect.h, duration: rect.duration, hash: hashFrame(image, rect.x, rect.y, rect.w, rect.h) } }) - return { ok: true, image, png: encodePng(image), frames, animations, sheet: sheet === undefined ? undefined : sheetFile, lint: await lintOf(asset, image, frames, undefined) } + return { ok: true, width: image.width, height: image.height, png: encodePng(image), frames, animations, sheet: sheet === undefined ? undefined : sheetFile, lint: await lintOf(asset, image, frames, undefined) } } catch (error) { return { ok: false, problem: message(error) } } @@ -343,8 +351,8 @@ export const entryOf = async (asset: PixelAsset): Promise => { return { ...base, kind: got.sheet === undefined ? "still" : "sheet", - width: got.image.width, - height: got.image.height, + width: got.width, + height: got.height, frames: got.frames, animations: got.animations, errors: got.lint.ok ? got.lint.errors : 0, @@ -360,12 +368,14 @@ export const libraryOf = (assets: ReadonlyArray): Promise => { const got = await readAsset(asset) + const config = configOf(asset.file) return { id: asset.id, file: asset.file, source: asset.source === undefined ? undefined : idOf(asset.source), sheet: got.ok && got.sheet !== undefined ? idOf(got.sheet) : undefined, - config: got.ok && got.lint.ok ? got.lint.config : undefined, + // The rules it would be linted against, even while it cannot be read. + config: config === undefined ? undefined : idOf(config), issues: got.ok && got.lint.ok ? got.lint.issues : [], version: await assetVersion(asset) } diff --git a/packages/tools/src/pixel/review/lab.ts b/packages/tools/src/pixel/review/lab.ts index 0972e9cb..8f60eaa8 100644 --- a/packages/tools/src/pixel/review/lab.ts +++ b/packages/tools/src/pixel/review/lab.ts @@ -492,7 +492,7 @@ const renderCounter = (): void => { const animation = animationOf(entry) const frame = entry.frames[screen.global] // Frames count from 0, as comments and `pixel notes` count them. - replace(screen.counter, `frame ${screen.place} of ${animation.frames.length} · ${frame?.duration ?? 0} ms`) + replace(screen.counter, h("span", { class: "pixel-counter-word" }, "frame "), `${screen.place} of ${animation.frames.length} · ${frame?.duration ?? 0} ms`) } /** The pixel of the frame under the pointer, in the middle copy's pixels when it repeats; `undefined` off the art. */ @@ -638,8 +638,8 @@ const renderTopbar = (): void => { segmented( state.view, [ - { id: "asset", label: "Asset", icon: "image", title: "One asset at a time, zoomed (Tab: the whole set)" }, - { id: "set", label: "Set", icon: "layers", title: "The whole set side by side at its own size, to judge it as one game (Tab: one asset)" } + { id: "asset", label: "Asset", icon: "image", title: "One asset at a time, zoomed (V: the whole set)" }, + { id: "set", label: "Set", icon: "layers", title: "The whole set side by side at its own size, to judge it as one game (V: one asset)" } ], setView, { label: "View", class: "pixel-views" } @@ -1229,9 +1229,10 @@ const renderChecks = (): void => { title: "Rules", help: "The lint against this asset's nearest pixel.config.json, the rules the agent checks with `ritmo-tools pixel lint`: its size, palette, colors, transparency, and stray pixels, each frame of a sheet apart. Warnings to weigh, not a verdict: you judge how it looks.", onToggle: renderChecks, - action: h("span", { class: "meta" }, entry.problem !== undefined ? "" : entry.errors + entry.warnings === 0 ? "clean" : `${entry.errors} errors · ${entry.warnings} warnings`) + action: h("span", { class: "meta" }, entry.problem !== undefined ? "" : entry.errors + entry.warnings === 0 ? "clean" : `${entry.errors} error${entry.errors === 1 ? "" : "s"} · ${entry.warnings} warning${entry.warnings === 1 ? "" : "s"}`) }, entry.lintProblem !== undefined && callout("warn", `Cannot lint it: ${entry.lintProblem}`), + entry.problem !== undefined && hint("Not linted: it cannot be read."), entry.problem === undefined && entry.lintProblem === undefined && (detail === undefined @@ -1366,7 +1367,7 @@ const renderAbout = (): void => h("code", {}, "pixel.review.json"), " at the game's root. Fixing pixels by hand belongs on the pixel fixer's page." ), - h("p", { class: "hint" }, "Keys: ↑ ↓ change asset, Space plays, ← → step frames, + − zoom, 0 back to 1×, Tab the whole set, Esc drops a comment being drawn.") + h("p", { class: "hint" }, "Keys: ↑ ↓ change asset, Space plays, ← → step frames, + − zoom, 0 back to 1×, V the whole set and back, Esc drops a comment being drawn.") ) ) @@ -1416,7 +1417,7 @@ function update(): void { renderAbout() } -// The arrows move through the list and the frames; Space plays; + and − zoom; Ctrl/Cmd+Z undoes. +// The arrows move through the list and the frames; Space plays; + and − zoom; V switches the view; Ctrl/Cmd+Z undoes. window.addEventListener("keydown", (event) => { const target = event.target if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) { @@ -1448,8 +1449,8 @@ window.addEventListener("keydown", (event) => { setZoom((state.view === "set" ? state.setZoom : state.zoom) - 1) } else if (event.key === "0") { setZoom(1) - } else if (event.key === "Tab" && !event.shiftKey && target === document.body) { - event.preventDefault() + } else if (event.key === "v" || event.key === "V") { + // Not Tab: that key belongs to moving through the page's controls. setView(state.view === "asset" ? "set" : "asset") } else if (event.key === "Escape" && (state.composing !== undefined || state.marks !== undefined)) { state.composing = undefined diff --git a/packages/tools/src/pixel/review/style.css b/packages/tools/src/pixel/review/style.css index 719b6626..9bf4df8a 100644 --- a/packages/tools/src/pixel/review/style.css +++ b/packages/tools/src/pixel/review/style.css @@ -29,6 +29,12 @@ overflow-wrap: anywhere; } +/* The files an asset comes from: long paths wrap rather than run past the panel. */ +.inspector .prop-control code { + overflow-wrap: anywhere; + white-space: normal; +} + .pixel-source { color: #8a8f98; } @@ -324,4 +330,9 @@ .pixel-set-foot { max-width: 96px; } + + /* "2 of 7 · 125 ms": the playback bar stays one row on a phone. */ + .pixel-counter-word { + display: none; + } } diff --git a/packages/tools/test/pixel-review.test.ts b/packages/tools/test/pixel-review.test.ts index 60811a42..317340b8 100644 --- a/packages/tools/test/pixel-review.test.ts +++ b/packages/tools/test/pixel-review.test.ts @@ -156,6 +156,16 @@ describe("finding a game's art", () => { // Linted against sweetie-16 at the root, each of the five frames apart: pure black and white are off its palette in each. const issues = sheetRead?.ok === true && sheetRead.lint.ok ? sheetRead.lint.issues.filter((issue) => issue.rule === "off-palette") : [] expect(issues.map((issue) => issue.frame)).toEqual([0, 1, 2, 3, 4]) + // A frame left empty is an error on that frame. + const blank = decodePng(readFileSync(join(folder, "public/art/hero.sheet.png"))) + const layout = readSpritesheet(JSON.parse(readFileSync(join(folder, "public/art/hero.sheet.json"), "utf8")), blank) + const box = layout?.ok === true ? layout.sheet.frames[1] : undefined + if (box === undefined || hero === undefined) throw new Error("the sheet has no second frame") + for (let y = box.y; y < box.y + box.h; y++) blank.data.fill(0, (y * blank.width + box.x) * 4, (y * blank.width + box.x + box.w) * 4) + writeFileSync(join(folder, "public/art/hero.sheet.png"), encodePng(blank)) + utimesSync(join(folder, "public/art/hero.sheet.png"), new Date(), new Date(Date.now() + 5000)) + const blankRead = await readAsset(hero) + expect(blankRead.ok && blankRead.lint.ok ? blankRead.lint.issues.filter((issue) => issue.rule === "empty-frame").map((issue) => issue.frame) : undefined).toEqual([1]) }) it("says why a sheet whose frames are off its picture cannot be read, and goes on", async () => { From d989c0b36f765c00a63f1deb230d964f1fc98cf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 22:36:03 +0000 Subject: [PATCH 7/9] ritmo-tools pixel review: ship the page, and document it The built review page ships in dist/pixel/page and its compiled source is left out, as for the other review pages; pack-check holds both. The README, the ritmo-pixel-art skill, and the website's tools page say what the review is for and how an agent acts on it, and the skill's Judging rule now says an approval on the page still leaves the art to be seen in the game at display size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HendNec6exyZNa2zBVD977 --- .changeset/pixel-review.md | 15 ++++ packages/core/PIXEL-ART.md | 1 + packages/core/skills/ritmo-pixel-art/SKILL.md | 7 +- packages/tools/README.md | 78 ++++++++++++++++++- packages/tools/package.json | 5 +- packages/tools/src/cli.ts | 2 +- scripts/pack-check.ts | 4 +- website/src/pages/tools/index.astro | 12 +-- 8 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 .changeset/pixel-review.md diff --git a/.changeset/pixel-review.md b/.changeset/pixel-review.md new file mode 100644 index 00000000..1806ffd0 --- /dev/null +++ b/.changeset/pixel-review.md @@ -0,0 +1,15 @@ +--- +"@typeonce/ritmo-tools": minor +"@typeonce/ritmo": patch +--- + +`ritmo-tools pixel`: review all of a game's pixel art with a person on one page, and read back what they said. + +- **`pixel review `** serves a local page (port 5193) with every PNG and text sprite under the folders named, by folder, each with its status. A person looks at each asset at whole-number zooms from 1× over a dark, light, checkerboard, or custom backdrop, plays sheets by animation with their durations, sees a tile repeated, lines the whole set up side by side at its own size (picking several to compare or approve at once), approves or asks for changes, and drags over the art to pin a comment to an area of pixels on one frame. Each asset's lint against its nearest `pixel.config.json` shows beside it. What the tools write beside the art (previews, project PNGs, onion pictures, scaled copies, frame folders) is left out, and a fix shows as one row with what it was made from. +- **`pixel notes `** prints a `summary` of what is left, then each asset's status, note, open comments (numbered, with their area and frame), how many are resolved, its lint, and `next`, changes asked first. **`pixel resolve --reply "..."`** marks comments done; the open page shows them with the reply. +- **Approval keeps the pixels the person saw**: a hash of each frame as decoded, and its duration. A re-export that looks the same is no change, and neither is a color hidden under a transparent pixel. When an approved asset later looks different or plays at other timings, `pixel notes` and the page say which frames under `changedSinceApproved`, and the page offers **Approve it as it is now**. +- The review lives in one `pixel.review.json` beside the game's `package.json`, keyed by path, out of the folder that ships. +- `review`, `notes`, and `resolve` exit 0 or 2 only. +- `pixel lint` no longer runs out of memory on a picture of thousands of colors (a smooth app icon took gigabytes comparing every pair): past 512 colors it says the picture is not pixel art yet instead. On the review page and in `pixel notes`, a sheet frame with nothing drawn in it is an `empty-frame` error. + +The `ritmo-pixel-art` skill says when to open the review and how to act on it, and that approving on the page does not replace looking at the art in the game at display size. diff --git a/packages/core/PIXEL-ART.md b/packages/core/PIXEL-ART.md index ede2829b..98f91d38 100644 --- a/packages/core/PIXEL-ART.md +++ b/packages/core/PIXEL-ART.md @@ -157,6 +157,7 @@ Quotes were checked against the text of the linked page on 2026-11-01. A quote m - Wesnoth wiki ([link][wesnoth]): "Test your unit early in the game." - Yu ([link][yu]): "if you squint, a few large clusters of light and dark should still emerge." - **Ritmo:** every Still Time review was written from phone captures of real rooms, and after lighting shipped the reviewer wrote "Judge your art under it. Append `?nolight` to the URL to compare against the unlit scene." In the `ritmo-prototype` skill one screenshot is the whole check in a round about looks; this skill asks for the same captures before art goes in. "When the target screen is out of reach, say so" is the `ritmo-prototype` rule of reporting what the check did not run. +- **Ritmo:** "and so does the review page" is the maintainer's direction for `pixel review` (2026-11-04): an approval on the page is the person's, but the page shows each asset alone, at whole-number zooms over a plain backdrop, so it settles the pixels and not the read; the game capture still does. ### A reference is a set of named qualities; change what was asked and nothing else; options at display size and 4× beside it diff --git a/packages/core/skills/ritmo-pixel-art/SKILL.md b/packages/core/skills/ritmo-pixel-art/SKILL.md index c1a2d86c..6ebe06c9 100644 --- a/packages/core/skills/ritmo-pixel-art/SKILL.md +++ b/packages/core/skills/ritmo-pixel-art/SKILL.md @@ -1,6 +1,6 @@ --- name: ritmo-pixel-art -description: "Apply whenever a game's pixel art is made, changed, laid out, or checked: the game's grid and scale, the camera, scaling, parallax, or text rendering of a pixel-art game, a sprite drawn with code, a picture from an image generator, a reference image to turn into game art, tiles and sprites judged together in a screenshot, or assets checked before they ship (effects: the `ritmo-fx` skill). Holds the principles that make a game's pixel art read as one game, and says which `ritmo-tools pixel` command to run for each case and how to read its report." +description: "Apply whenever a game's pixel art is made, changed, laid out, or checked: the game's grid and scale, the camera, scaling, parallax, or text rendering of a pixel-art game, a sprite drawn with code, a picture from an image generator, a reference image to turn into game art, tiles and sprites judged together in a screenshot, assets checked before they ship, or a game's art reviewed with a person on a page where they approve it and pin comments to areas of pixels (effects: the `ritmo-fx` skill). Holds the principles that make a game's pixel art read as one game, and says which `ritmo-tools pixel` command to run for each case and how to read its report." --- # Pixel art for a game @@ -37,7 +37,7 @@ Pixel art is a decision about the whole game, not one sprite at a time: one grid ### Judging -- **Judge in the game, at display size, on the target screen.** A sprite is one piece of a crowded screen: look at captures of real scenes at display size, on the smallest screen the game targets, under the game's lighting if it has any, still and in motion, before anything goes in. Squint: the shapes that matter must still be there. A zoomed-in canvas shows the pixels, not the read. When the target screen is out of reach, say so; the person who plays judges there. +- **Judge in the game, at display size, on the target screen.** A sprite is one piece of a crowded screen: look at captures of real scenes at display size, on the smallest screen the game targets, under the game's lighting if it has any, still and in motion, before anything goes in. Squint: the shapes that matter must still be there. A zoomed-in canvas shows the pixels, not the read, and so does the review page: an asset the person approved there, alone at whole-number zooms, is still looked at in the game at display size. When the target screen is out of reach, say so; the person who plays judges there. - **A reference is a set of named qualities, not a picture to copy.** Say what the reference gives (scale, proportion, palette, mood, detail level) and what the game keeps regardless; say what is not wanted too. Change what was asked for and nothing else, and show two or three options at display size and zoomed 4× beside the reference and in a game capture before integrating one. - **An exception is a decision, written down.** A second pixel size, a rotating sprite, a smooth parallax, a broken constraint: each is fine when the person chooses it for a reason and the game's design notes say why, and wrong when it happens to one asset. A clean lint, or two agents agreeing, never settles a look; the person who plays does. @@ -54,6 +54,8 @@ Every command prints a JSON report on stdout and exits with what to do next: | 3 | `fixable`: rules broken | Run `pixel fix` (a picture) or edit the source (a sprite), then check again. | | 4 | `needs-person`: the grid is unsure | Open `.preview.png`; give the grid with `--cell` and `--offset` if you can read it, or hand the recipe to the person (it opens in the web page). Never ship a needs-person fix as done. | +`pixel review`, `pixel notes`, and `pixel resolve` exit 0 or 2 only: the lint they carry is for you to weigh. + ### The project's rules A `pixel.config.json` at the game's root (`pixel init -p ` writes one) holds the palette, the allowed sizes, the color limit, and whether the background must be transparent. Every command reads it from the working directory up, so never repeat them on the command line; change the config instead when the rules change. `pixel palettes` lists the presets. @@ -69,6 +71,7 @@ A `pixel.config.json` at the game's root (`pixel init -p ` writes one) You cannot watch an animation: read each `.onion.png`, a panel per step with the frame before in red and the frame after in blue. A thick band is a jump, a drift, or a change of size, so fix it with `--anchor` or a nudge in the recipe, or redo that frame; the first panel's red is the last frame, where the loop closes. The person watches the `.gif` and judges the motion. - **A reference image (a drawing, a render, a photo) to turn into game art.** `pixel pixelate ref.png -w 32` cuts it into 32 pixels across on the project's palette (or `--colors N` proposed from the picture). Pixelating loses detail by design: compare widths by their previews, keep the smallest one that still reads, then touch up the `.sprite.txt` and `render` it. - **A tile that repeats.** `pixel lint tile.png --tile both` (`x` for one that only repeats side by side, `y` one over another) warns of a `seam` when its edges do not meet; `pixel preview tile.png --tile both` writes it repeated. Read that picture whatever the lint says: it finds hard seams only, and whether a repeat hides the grid is judged by eye ([Tiles](#one-style)). +- **Reviewing the art with the person.** `pixel review ` with the folders the game's art is in (`public/art`, `assets/sprites`), not the whole game; it runs until stopped, so start it in the background (`--host` for a phone), and send the person the `url` it prints. The page lists every asset by folder, shows each at whole-number zooms over a backdrop the person picks, plays sheets by animation, lines the whole set up side by side, and lints each against its nearest `pixel.config.json`. Read back with `pixel notes `, the same paths: a `summary`, then each asset's status, note, and open comments, each numbered on an area of one frame (`where`), changes first. Look at that area before you change anything, fix the asset where it is made (a `source` in the notes is the sprite or picture its PNG comes from: change that, and make the PNG again), and mark each comment done with `pixel resolve --reply "what changed"`. `changedSinceApproved` means an approved asset no longer looks as the person saw it (which frames, or their timing): tell them what changed and ask them to look again; approving is theirs. `gone` lists reviews of files that moved: move or remove each in `pixel.review.json`. - **Checking assets before they ship.** `pixel lint` each PNG at its true size (exit 0 for all of them), or `pixel check` for pictures that may be upscaled. `pixel compare a.png b.png` says which of two versions is better and why. ### Sprite sources diff --git a/packages/tools/README.md b/packages/tools/README.md index 412dd1b2..bf4d1630 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -6,7 +6,7 @@ tool per kind of asset: | Tool | For | | --- | --- | -| `ritmo-tools pixel` | Pixel art: fixes AI-made or blurry pictures and animation sheets onto a true pixel grid and a palette, renders sprites drawn as text, and checks a project's art against its rules. | +| `ritmo-tools pixel` | Pixel art: fixes AI-made or blurry pictures and animation sheets onto a true pixel grid and a palette, renders sprites drawn as text, checks a project's art against its rules, and opens a local page with all of a game's art by folder, where a person looks at each piece at whole-number zooms, plays its animations, sees the whole set side by side, approves, and pins comments to an area of pixels. | | `ritmo-tools sound` | Sound effects: keeps them as JSON recipes, measures and varies them, writes WAV files, and opens a local page where a person listens, picks, tunes, and notes. | | `ritmo-tools fx` | Pixel art effects: keeps each effect as a few drafts written as code, checks them for pixel art craft and the project's rules, draws contact sheets an agent can look at, writes sprite sheets and GIFs, and opens a local page where a person watches the drafts side by side, picks, tunes, and pins comments to a pixel. | | `ritmo-tools music` | Game music: hears a person's hum for its tune, tempo, and key, keeps songs as JSON tracks of notes, checks them, writes WAV and MIDI files, and opens a local page with all of a game's music on one list, songs and plain audio files, where a person plays, edits notes, records, approves, and comments. | @@ -38,7 +38,9 @@ sheet with its JSON, a GIF, and each frame. It comes in two parts that share one core: a web page for people tuning art by hand (it runs entirely in the browser; pictures never leave the device), and the `ritmo-tools pixel` command for agents that check, fix, and make art on their own. -A recipe saved by one runs in the other. +A recipe saved by one runs in the other. The command also opens a review +page on localhost, where a person goes through all of a game's art and the +agent reads back what to change (see "The review page" below). The page wears Ritmo's brand (`brand/README.md`): its colors, its mark as the logo, and its Tick orange only on what is active or selected. The palette @@ -296,6 +298,12 @@ the source), 4 needs a person (the grid is unsure: look at the preview). | `compare
` | Which of two versions is better, and why. | | `palettes [name]` | The presets, or one palette's colors. | | `init` | Writes a starting `pixel.config.json`. | +| `review [--port 5193] [--host]` | Serves the review page for the PNGs, text sprites, and folders named, and prints its address; runs until stopped. | +| `notes ` | What the person said about the same paths: a `summary` of what is left, then `assets`, each one's status, note, open comments (numbered, with their area and frame), how many are resolved, `changedSinceApproved`, the lint, and `next`, changes asked first; `gone` lists reviews whose file is no longer found. | +| `resolve [--reply "what changed"]` | Marks comments as resolved: `pixel notes` stops listing them, and the page shows them done with the reply. The person can reopen one. | + +`review`, `notes`, and `resolve` exit 0 or 2 only: what the lint says about +the art is in the notes, never a failure of them. `fix`, `check`, `pixelate`, and `preview` take the page's settings as flags (`--palette`, `--cell 8x8 --offset 2,3`, `--follow straight` or `bend`, @@ -380,6 +388,63 @@ of the true size, which are as clean but have half their lines mid-cell. browser. If it fails, download the `.hex` file from lospec.com and import it. - It fixes pixels and colors, not the drawing itself. +### The review page + +`ritmo-tools pixel review public/art assets/sprites` opens a game's art on a +local page, where a person goes through it and says what to change. Name the +folders the art is in: a folder is searched for PNGs and text sprites, and a +folder with none, or a path that is not there, exits 2. Left out of a folder: +`node_modules`, hidden folders, and what the tools write beside the art +(`.preview.png`, `.project.png`, `.onion.png`, `@Nx.png` and its JSON, +`.frames/` folders). A fix and what it was made from (`hero.fixed.png` and +`hero.png`; likewise `.sheet.png`, `.pixel.png`, and a sprite's +`.render.png`) show as one row: the file the game uses, with its source +named. A `.txt` file is art only when it reads as a sprite source (others, +notes or prompts, are passed over); named outright, one that does not read +exits 2. A PNG with a JSON beside it in the format `pixel sheet` writes (or +Aseprite's) is a sheet, played by animation with each frame's duration; +with a JSON in a format of its own, it shows as a still. + +The list on the left shows every asset by folder with its status, whether it +changed since it was approved, its lint, and its open comments; the top bar +narrows it to what is to review, has changes asked, or is approved. The art +shows at a whole-number zoom (1× first, the size the game draws it before +any scaling) over a backdrop: dark, light, a checkerboard, or any color. +**Repeat** draws it three by three, to see a tile meet itself. A sheet +plays each animation with its durations, and the playback bar pauses and +steps through the frames. **Set** shows every asset at its own size side by +side over one backdrop, to see whether they belong together; pick several +and **Compare them** shows only those, or **Approve them** approves them +at once. + +Dragging over the art pins a comment to that area of the frame shown (a +click pins it to one pixel); each comment's number rings its area, at every +zoom. On the right are the review (status and note), the comments (resolved +ones greyed, with the agent's reply, and **Reopen**), the lint against the +asset's nearest `pixel.config.json` (**Show** rings a rule's pixels on its +frame), and the files the asset comes from. **Approved** keeps a hash of +each frame's pixels as decoded and its duration: a re-export that looks the +same is no change, and neither is a color hidden under a transparent pixel. +When the art later looks different or plays at other timings, the page and +`pixel notes` say which frames, and **Approve it as it is now** takes the new +version; the status stays approved, since only the person changes it. + +↑ and ↓ change asset, Space plays, ← and → step frames, + and − zoom, 0 goes +back to 1×, V switches to the whole set and back, and Ctrl/Cmd+Z undoes. +Every change is saved as it happens. Art the agent rewrites or adds, and +comments it resolves, show up while the page is open; what the person is +typing is kept. + +The review is not kept beside the art, which usually sits in a folder that +ships with the game: all of it lives in one `pixel.review.json`, keyed by the +path from there, in the nearest folder above the art with a `package.json` +(or the art's own, when there is none). + +Approving on the page is not judging the art in the game: the page shows it +alone at whole-number zooms, and the game shows it among the rest, scaled, +moving, and over its own ground. Look at it in the game at the size it is +drawn too. + ### Code - `src/pixel/core/`: the headless core, tested in `test/` with fake AI pictures made @@ -398,7 +463,8 @@ of the true size, which are as clean but have half their lines mid-cell. `preview.ts` (pictures to look at), `sheet.ts` (clearing a sheet, finding its frames, anchors, and laying frames on one canvas), `animation.ts` (the whole fix for a sheet), `spritesheet.ts` (sheet layouts and their JSON), - `gif.ts` (animated GIFs), `zip.ts` (stored ZIP files). The outline + `gif.ts` (animated GIFs), `zip.ts` (stored ZIP files), `review.ts` (the + review file, approval hashes, and the notes). The outline handling, bent lines, and the outline-keeping shrink follow [ToPixel](https://github.com/daisuqe/ToPixel). - `src/pixel/ui/`: the page (plain DOM and a canvas, no framework; `player.ts` plays @@ -408,7 +474,11 @@ of the true size, which are as clean but have half their lines mid-cell. - `src/pixel/cli/`: the `pixel` command (Effect), with PNG files through `pngjs` and JPEG through `jpeg-js`; `src/cli.ts` is `ritmo-tools` itself, and `bin/ritmo-tools.mjs` runs it from - its TypeScript source inside the repository. + its TypeScript source inside the repository. `library.ts` finds a game's art, + reads, hashes, and lints each asset, and keeps the review file; `serve.ts` is + the review page's server. +- `src/pixel/review/`: the review page (`lab.ts`), which only draws what the + server decoded, and the store it reads and writes through (`store.ts`). ## Effects lab (`ritmo-tools fx`) diff --git a/packages/tools/package.json b/packages/tools/package.json index 2dc9d5e3..09c886ef 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,7 +1,7 @@ { "name": "@typeonce/ritmo-tools", "version": "0.20.0", - "description": "Asset tools for games made with Ritmo, for agents: fixes AI-made pixel art and animation sheets onto a true pixel grid and a palette, keeps game sounds as JSON recipes, writes game music as JSON songs built around a hum, and draws pixel art effects as code, each with a review page for a person", + "description": "Asset tools for games made with Ritmo, for agents: fixes AI-made pixel art and animation sheets onto a true pixel grid and a palette, keeps game sounds as JSON recipes, writes game music as JSON songs built around a hum, and draws pixel art effects as code, each with a review page for a person, pixel art included", "author": "Sandro Maglione", "license": "MIT", "repository": { @@ -28,6 +28,7 @@ "files": [ "dist", "!dist/pixel/ui", + "!dist/pixel/review", "!dist/sound/ui", "!dist/music/ui", "!dist/fx/ui", @@ -72,7 +73,7 @@ "scripts": { "build": "tsc -b tsconfig.json --pretty false", "check": "tsc -b tsconfig.json --pretty false", - "build:page": "vite build src/sound/ui --outDir ../../../dist/sound/page --emptyOutDir --base ./ --logLevel warn && vite build src/music/ui --outDir ../../../dist/music/page --emptyOutDir --base ./ --logLevel warn && vite build src/fx/ui --outDir ../../../dist/fx/page --emptyOutDir --base ./ --logLevel warn", + "build:page": "vite build src/pixel/review --outDir ../../../dist/pixel/page --emptyOutDir --base ./ --logLevel warn && vite build src/sound/ui --outDir ../../../dist/sound/page --emptyOutDir --base ./ --logLevel warn && vite build src/music/ui --outDir ../../../dist/music/page --emptyOutDir --base ./ --logLevel warn && vite build src/fx/ui --outDir ../../../dist/fx/page --emptyOutDir --base ./ --logLevel warn", "prepack": "pnpm run build:page" }, "dependencies": { diff --git a/packages/tools/src/cli.ts b/packages/tools/src/cli.ts index 8b97dc08..b92bbfb2 100644 --- a/packages/tools/src/cli.ts +++ b/packages/tools/src/cli.ts @@ -2,7 +2,7 @@ /** * `ritmo-tools`: the asset tools for games made with Ritmo, run by agents. * - * ritmo-tools pixel checks, fixes, and makes pixel art and animations + * ritmo-tools pixel checks, fixes, and makes pixel art and animations, and opens the review page * ritmo-tools sound checks, renders, and varies game sounds, and opens the review page * ritmo-tools music listens to a hum, checks and renders songs, and opens the review page * ritmo-tools fx checks, looks at, and renders pixel art effects written as code, and opens the review page diff --git a/scripts/pack-check.ts b/scripts/pack-check.ts index e40c9163..fe43ae58 100644 --- a/scripts/pack-check.ts +++ b/scripts/pack-check.ts @@ -51,7 +51,7 @@ for (const directory of packages) { tarballByName.set(manifest.name, path.join(tarballs, created)) const listing = run("tar", ["-tzf", path.join(tarballs, created)], work).split("\n") const shipped = commandLineOnly.has(directory) - ? ["package/LICENSE", "package/README.md", "package/CHANGELOG.md", "package/dist/cli.js", "package/dist/sound/page/index.html", "package/dist/music/page/index.html", "package/dist/fx/page/index.html"] + ? ["package/LICENSE", "package/README.md", "package/CHANGELOG.md", "package/dist/cli.js", "package/dist/pixel/page/index.html", "package/dist/sound/page/index.html", "package/dist/music/page/index.html", "package/dist/fx/page/index.html"] : ["package/LICENSE", "package/README.md", "package/CHANGELOG.md", "package/dist/index.js", "package/dist/index.d.ts", "package/src/index.ts"] for (const required of shipped) { if (!listing.includes(required)) throw new Error(`${manifest.name} tarball is missing ${required}`) @@ -66,7 +66,7 @@ for (const directory of packages) { for (const repoOnly of ["PROTOTYPE.md", "ONBOARDING.md", "POLISH.md", "FX.md", "PIXEL-ART.md"]) if (listing.includes(`package/${repoOnly}`)) throw new Error(`${manifest.name} tarball contains the repo-only ${repoOnly}`) if (listing.some((file) => file.startsWith("package/genres/"))) throw new Error(`${manifest.name} tarball contains the repo-only genres/`) } - if (commandLineOnly.has(directory) && listing.some((file) => file.startsWith("package/src/") || file.startsWith("package/bin/") || file.startsWith("package/dist/pixel/ui/") || file.startsWith("package/dist/sound/ui/") || file.startsWith("package/dist/music/ui/") || file.startsWith("package/dist/fx/ui/"))) { + if (commandLineOnly.has(directory) && listing.some((file) => file.startsWith("package/src/") || file.startsWith("package/bin/") || file.startsWith("package/dist/pixel/ui/") || file.startsWith("package/dist/pixel/review/") || file.startsWith("package/dist/sound/ui/") || file.startsWith("package/dist/music/ui/") || file.startsWith("package/dist/fx/ui/"))) { throw new Error(`${manifest.name} tarball contains its source, its in-repo launcher, or the website's page`) } if (directory === "pixi" && listing.some((file) => /\/(NodeRegistry|RenderSync)\.(ts|js|d\.ts)$/.test(file))) { diff --git a/website/src/pages/tools/index.astro b/website/src/pages/tools/index.astro index 9fdceb46..34ec90dd 100644 --- a/website/src/pages/tools/index.astro +++ b/website/src/pages/tools/index.astro @@ -1,8 +1,9 @@ --- // The front door to `@typeonce/ritmo-tools`: the command an agent runs for -// art, effects, sound, and music, and the four pages a person reviews on. The tools' -// own pages (/tools/pixel/, /tools/fx/, /tools/sound/, /tools/music/) own the whole -// window; this one is a guide. +// art, effects, sound, and music, and the four pages on this site a person +// tries them on. The tools' own pages (/tools/pixel/, /tools/fx/, /tools/sound/, +// /tools/music/) own the whole window; this one is a guide. The pixel art review +// (`pixel review`) runs on localhost over the game's own files, so it has no page here. import Command from "../../components/Command.astro" import FxShowcase from "../../components/FxShowcase.astro" import MusicShowcase from "../../components/MusicShowcase.astro" @@ -16,14 +17,15 @@ const tools = [ { id: "pixel", title: "Pixel fixer", - body: "Image generators fake pixel art and draw uneven animation sheets. The fixer turns both into real pixel art: a true grid, one palette, frames that play smooth.", + body: "Image generators fake pixel art and draw uneven animation sheets. The fixer turns both into real pixel art: a true grid, one palette, frames that play smooth. Then you go through all of a game's art on one page, approve it, and circle the pixels to change.", href: "/tools/pixel/", open: "Open the pixel fixer", command: "npx @typeonce/ritmo-tools pixel fix art.png", commands: [ { name: "check", body: "Clean, fixable, or needs a person?" }, { name: "fix", body: "The art at its true size, and its recipe." }, - { name: "sheet", body: "Frames on one grid, a sprite sheet, a GIF." } + { name: "sheet", body: "Frames on one grid, a sprite sheet, a GIF." }, + { name: "review", body: "All the art by folder, to approve and comment on." } ] }, { From ffc70fe3d3cf5ba850a011a0fa1ba50ca0aabcee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 22:41:14 +0000 Subject: [PATCH 8/9] ritmo-tools pixel review: say a picture that does not decode is not a PNG or a JPEG The decoder's own words ("unrecognised content at end of stream") named nothing the person could act on; the other pixel commands already say it this way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HendNec6exyZNa2zBVD977 --- packages/tools/src/pixel/cli/library.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/tools/src/pixel/cli/library.ts b/packages/tools/src/pixel/cli/library.ts index 21c86197..91be4ab9 100644 --- a/packages/tools/src/pixel/cli/library.ts +++ b/packages/tools/src/pixel/cli/library.ts @@ -210,7 +210,14 @@ const read = async (asset: PixelAsset): Promise => { const frames: ReadonlyArray = [{ name: "", index: 0, x: 0, y: 0, w: image.width, h: image.height, hash: hashFrame(image, 0, 0, image.width, image.height) }] return { ok: true, width: image.width, height: image.height, png: encodePng(image), frames, animations: [], sheet: undefined, lint: await lintOf(asset, image, frames, parsed.art) } } - const image = decodePicture(await readFile(asset.file)) + const bytes = await readFile(asset.file) + let image: RgbaImage + try { + image = decodePicture(bytes) + } catch (error) { + // Said as the other pixel commands say it: the decoder's words alone ("unrecognised content at end of stream") name no file. + return { ok: false, problem: `it is not a PNG or a JPEG that decodes: ${message(error)}` } + } const sheetFile = sheetFileOf(asset) const json: unknown = sheetFile === undefined ? undefined : await readFile(sheetFile, "utf8").then((text): unknown => JSON.parse(text)).catch(() => undefined) const sheet = readSpritesheet(json, image) From 178a7daec08fa1ff7b980a1b8bdced99f1fa2f83 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Thu, 5 Nov 2026 10:16:04 +0900 Subject: [PATCH 9/9] ritmo-tools pixel review: open art as large as fits, and lay the set out on a grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tried on the real games' art: at 1× a 24×24 sprite is too small to judge or to press on, and big pictures made the stage scroll. - An asset opens at the largest whole zoom that fits, up to 16×, and a picture too big for the stage shrinks to fit. 0 shows it at 1×, a zoom the person picks stays for the next asset, and F fits again. A zoom kept from a small asset no longer makes a canvas too big to draw. - It fits again when the stage changes size: WebKit first measures it before it has its full height. - The set is a grid by size, at one scale taken from its median asset. A picture too big for the grid is smaller and says its own zoom. - Nothing moves when it needn't: 1× and Fit stay in the bar, and the picks bar stays above the set, stuck 8px under the top bar as it scrolls. - The top bar's two pills line up, and so do the icons in the list and tiles. The View section starts closed. Co-Authored-By: Claude Opus 5.5 --- .changeset/pixel-review.md | 2 +- packages/tools/README.md | 15 +- packages/tools/src/pixel/cli/commands.ts | 2 +- packages/tools/src/pixel/review/lab.ts | 208 ++++++++++++++++++---- packages/tools/src/pixel/review/style.css | 99 ++++++++-- 5 files changed, 263 insertions(+), 63 deletions(-) diff --git a/.changeset/pixel-review.md b/.changeset/pixel-review.md index 1806ffd0..7118f5f3 100644 --- a/.changeset/pixel-review.md +++ b/.changeset/pixel-review.md @@ -5,7 +5,7 @@ `ritmo-tools pixel`: review all of a game's pixel art with a person on one page, and read back what they said. -- **`pixel review `** serves a local page (port 5193) with every PNG and text sprite under the folders named, by folder, each with its status. A person looks at each asset at whole-number zooms from 1× over a dark, light, checkerboard, or custom backdrop, plays sheets by animation with their durations, sees a tile repeated, lines the whole set up side by side at its own size (picking several to compare or approve at once), approves or asks for changes, and drags over the art to pin a comment to an area of pixels on one frame. Each asset's lint against its nearest `pixel.config.json` shows beside it. What the tools write beside the art (previews, project PNGs, onion pictures, scaled copies, frame folders) is left out, and a fix shows as one row with what it was made from. +- **`pixel review `** serves a local page (port 5193) with every PNG and text sprite under the folders named, by folder, each with its status. A person looks at each asset at whole-number zooms, opening as large as fits with 1× a key away, over a dark, light, checkerboard, or custom backdrop, plays sheets by animation with their durations, sees a tile repeated, lines the whole set up at one scale on a grid by size (picking several to compare or approve at once), approves or asks for changes, and drags over the art to pin a comment to an area of pixels on one frame. Each asset's lint against its nearest `pixel.config.json` shows beside it. What the tools write beside the art (previews, project PNGs, onion pictures, scaled copies, frame folders) is left out, and a fix shows as one row with what it was made from. - **`pixel notes `** prints a `summary` of what is left, then each asset's status, note, open comments (numbered, with their area and frame), how many are resolved, its lint, and `next`, changes asked first. **`pixel resolve --reply "..."`** marks comments done; the open page shows them with the reply. - **Approval keeps the pixels the person saw**: a hash of each frame as decoded, and its duration. A re-export that looks the same is no change, and neither is a color hidden under a transparent pixel. When an approved asset later looks different or plays at other timings, `pixel notes` and the page say which frames under `changedSinceApproved`, and the page offers **Approve it as it is now**. - The review lives in one `pixel.review.json` beside the game's `package.json`, keyed by path, out of the folder that ships. diff --git a/packages/tools/README.md b/packages/tools/README.md index bf4d1630..84be5516 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -408,12 +408,17 @@ with a JSON in a format of its own, it shows as a still. The list on the left shows every asset by folder with its status, whether it changed since it was approved, its lint, and its open comments; the top bar narrows it to what is to review, has changes asked, or is approved. The art -shows at a whole-number zoom (1× first, the size the game draws it before -any scaling) over a backdrop: dark, light, a checkerboard, or any color. +opens at the largest whole-number zoom that fits, up to 16×, so a small sprite +is big enough to judge and to pin a comment on, and a picture too big for the +stage shrinks to fit, so nothing scrolls until asked; 0 shows it at 1×, the size the +game draws it before any scaling, and a zoom the person picks stays for the +next asset until F fits again. It sits over a backdrop: dark, light, a +checkerboard, or any color. **Repeat** draws it three by three, to see a tile meet itself. A sheet plays each animation with its durations, and the playback bar pauses and -steps through the frames. **Set** shows every asset at its own size side by -side over one backdrop, to see whether they belong together; pick several +steps through the frames. **Set** shows every asset at one scale, chosen from the set's typical size, on +a grid by size over one backdrop, to see whether they belong together; a +picture too big for the grid is smaller and says its own zoom. Pick several and **Compare them** shows only those, or **Approve them** approves them at once. @@ -430,7 +435,7 @@ When the art later looks different or plays at other timings, the page and version; the status stays approved, since only the person changes it. ↑ and ↓ change asset, Space plays, ← and → step frames, + and − zoom, 0 goes -back to 1×, V switches to the whole set and back, and Ctrl/Cmd+Z undoes. +back to 1×, F fits each asset again, V switches to the whole set and back, and Ctrl/Cmd+Z undoes. Every change is saved as it happens. Art the agent rewrites or adds, and comments it resolves, show up while the page is open; what the person is typing is kept. diff --git a/packages/tools/src/pixel/cli/commands.ts b/packages/tools/src/pixel/cli/commands.ts index 7b95a539..a18ea5af 100644 --- a/packages/tools/src/pixel/cli/commands.ts +++ b/packages/tools/src/pixel/cli/commands.ts @@ -604,7 +604,7 @@ const review = Command.make( }) ).pipe( Command.withDescription( - "Opens the review page for all the pixel art under the paths: the person goes through it by folder, at whole-number zooms over a choice of backdrops, plays sheets by animation, sees the whole set side by side at its own size, approves each asset or asks for changes, and pins comments to an area of pixels on a frame. The lint against each asset's nearest pixel.config.json shows beside it. Runs until stopped; changes are saved as they happen." + "Opens the review page for all the pixel art under the paths: the person goes through it by folder, at whole-number zooms over a choice of backdrops, plays sheets by animation, sees the whole set side by side at one scale, approves each asset or asks for changes, and pins comments to an area of pixels on a frame. The lint against each asset's nearest pixel.config.json shows beside it. Runs until stopped; changes are saved as they happen." ) ) diff --git a/packages/tools/src/pixel/review/lab.ts b/packages/tools/src/pixel/review/lab.ts index 8f60eaa8..fa97d245 100644 --- a/packages/tools/src/pixel/review/lab.ts +++ b/packages/tools/src/pixel/review/lab.ts @@ -1,13 +1,13 @@ /** * The pixel art review page, served by `ritmo-tools pixel review ` * (`main.ts`): the person goes through a game's art on a list, by folder. - * Each asset opens at its own size (1×) and zooms by whole numbers, over a - * backdrop of their choosing; a sheet plays by animation, with its frames' + * Each asset opens as large as fits the stage and zooms by whole numbers, down + * to its own size (1×), over a backdrop of their choosing; a sheet plays by animation, with its frames' * own timings. They approve it, or ask for changes with a note and comments * pinned to an area of pixels on a frame; the lint against the asset's * nearest `pixel.config.json` shows beside it. The whole set also shows - * side by side at its own size, to judge the art as one game, and to - * compare and approve several at once. + * side by side at one scale, on a grid by size, to judge the art as one game, + * and to compare and approve several at once. * * The review is one file, saved whole as it changes (one undo history for * all of it), for the agent to read back with `ritmo-tools pixel notes`. Art @@ -63,10 +63,14 @@ const state = { view: "asset" as View, show: "all" as Show, search: "", - /** The asset's zoom, a whole number: 1 is the art at its own size. */ + /** The asset's zoom: 1 is the art at its own size. A whole number, or under 1 for a picture shrunk to fit. */ zoom: 1, + /** Whether each asset takes the zoom that fits it, until the person picks one. */ + fit: true, /** The whole set's zoom. */ setZoom: 1, + /** Whether the whole set takes the zoom its art's size asks for, until the person picks one. */ + setFit: true, /** Assets picked in the whole set, to compare or approve together. */ picked: new Set(), onlyPicked: false, @@ -379,12 +383,30 @@ const tiles = (): number => (state.repeat ? 3 : 1) /** The largest zoom that keeps the canvas a size a browser draws. */ const maxZoom = (entry: Entry): number => Math.max(1, Math.min(32, Math.floor(4096 / (Math.max(1, ...entry.frames.map((frame) => Math.max(frame.w, frame.h))) * tiles())))) +/** Past this a small sprite gains nothing on screen: its pixels are already easy to see and to press. */ +const FIT_MOST = 16 + +/** The zoom at which the asset's largest frame fits the stage without scrolling, once it is drawn there: the largest whole number, or for a picture too big at 1×, just what fits. */ +const fitZoom = (entry: Entry): number => { + const spot = content.querySelector(".pixel-screen") + if (spot === null) return 1 + const box = spot.getBoundingClientRect() + const room = content.getBoundingClientRect().bottom - box.top - content.scrollTop - parseFloat(getComputedStyle(content).paddingBottom) + const across = Math.max(1, ...entry.frames.map((frame) => frame.w)) * tiles() + const down = Math.max(1, ...entry.frames.map((frame) => frame.h)) * tiles() + const fits = Math.min((box.width - MARGIN * 2) / across, (room - MARGIN * 2) / down) + if (fits >= 1) return Math.min(FIT_MOST, maxZoom(entry), Math.floor(fits)) + return Math.max(0.01, Math.floor(fits * 100) / 100) +} + +const zoomWords = (zoom: number): string => `${zoom >= 1 ? zoom : zoom.toFixed(2)}×` + /** The canvas's size in page pixels: the largest frame of the animation, at the zoom, with the margin around it. */ const screenOf = (entry: Entry): { readonly w: number; readonly h: number; readonly fw: number; readonly fh: number } => { const frames = animationOf(entry).frames.map((i) => entry.frames[i]!) const fw = Math.max(1, ...frames.map((frame) => frame.w)) const fh = Math.max(1, ...frames.map((frame) => frame.h)) - return { w: fw * state.zoom * tiles() + MARGIN * 2, h: fh * state.zoom * tiles() + MARGIN * 2, fw, fh } + return { w: Math.ceil(fw * state.zoom * tiles()) + MARGIN * 2, h: Math.ceil(fh * state.zoom * tiles()) + MARGIN * 2, fw, fh } } const paint = (canvas: HTMLCanvasElement, entry: Entry, image: HTMLImageElement, global: number): void => { @@ -401,16 +423,18 @@ const paint = (canvas: HTMLCanvasElement, entry: Entry, image: HTMLImageElement, } context.setTransform(ratio, 0, 0, ratio, 0, 0) context.clearRect(0, 0, screen.w, screen.h) - context.imageSmoothingEnabled = false const zoom = state.zoom + // Whole zooms keep every pixel; a picture shrunk to fit has pixels to lose, and looks better blended. + context.imageSmoothingEnabled = zoom < 1 const [a, b] = backdropColors() const spanW = screen.fw * tiles() const spanH = screen.fh * tiles() - // The backdrop is in squares of 4 art pixels, so the pixel size shows. - for (let y = 0; y < spanH; y += 4) - for (let x = 0; x < spanW; x += 4) { - context.fillStyle = (x / 4 + y / 4) % 2 === 0 ? a : b - context.fillRect(MARGIN + x * zoom, MARGIN + y * zoom, Math.min(4, spanW - x) * zoom, Math.min(4, spanH - y) * zoom) + // The backdrop is in squares of 4 art pixels, so the pixel size shows; shrunk, in squares that still show on screen. + const square = zoom >= 1 ? 4 : Math.ceil(4 / zoom) + for (let y = 0; y < spanH; y += square) + for (let x = 0; x < spanW; x += square) { + context.fillStyle = (x / square + y / square) % 2 === 0 ? a : b + context.fillRect(MARGIN + x * zoom, MARGIN + y * zoom, Math.min(square, spanW - x) * zoom, Math.min(square, spanH - y) * zoom) } for (let ty = 0; ty < tiles(); ty++) for (let tx = 0; tx < tiles(); tx++) context.drawImage(image, frame.x, frame.y, frame.w, frame.h, MARGIN + tx * screen.fw * zoom, MARGIN + ty * screen.fh * zoom, frame.w * zoom, frame.h * zoom) @@ -639,7 +663,7 @@ const renderTopbar = (): void => { state.view, [ { id: "asset", label: "Asset", icon: "image", title: "One asset at a time, zoomed (V: the whole set)" }, - { id: "set", label: "Set", icon: "layers", title: "The whole set side by side at its own size, to judge it as one game (V: one asset)" } + { id: "set", label: "Set", icon: "layers", title: "The whole set side by side at one scale, to judge it as one game (V: one asset)" } ], setView, { label: "View", class: "pixel-views" } @@ -782,6 +806,8 @@ const renderHead = (): void => { ) } +let fitting = false + const renderStage = (): void => { screen = undefined if (state.view === "set") return renderSet() @@ -797,11 +823,13 @@ const renderStage = (): void => { renderPlayback() return } + // A zoom kept from a smaller asset may be more than a browser draws at this one's size. + state.zoom = Math.min(state.zoom, maxZoom(entry)) const size = screenOf(entry) const canvas = h("canvas", { class: "pixel-canvas", title: "Press on a pixel, or drag across an area, to comment on it (it pauses there)", - attrs: { "aria-label": `${entry.name} at ${state.zoom}×`, style: `width:${size.w}px;height:${size.h}px` } + attrs: { "aria-label": `${entry.name} at ${zoomWords(state.zoom)}`, style: `width:${size.w}px;height:${size.h}px` } }) const rings = h("div", { class: "pixel-rings", attrs: { "aria-hidden": "true" } }) const counter = h("span", { class: "frame-counter pixel-counter" }) @@ -847,15 +875,72 @@ const renderStage = (): void => { ), h("div", { class: "pixel-screen" }, h("div", { class: "pixel-frame", attrs: { style: `width:${size.w}px;height:${size.h}px` } }, canvas, rings)) ) + // The room left depends on what is drawn above the art, so it is measured here, and drawn once more at what it says. + if (state.fit && !fitting && fitZoom(entry) !== state.zoom) { + state.zoom = fitZoom(entry) + fitting = true + renderStage() + fitting = false + return + } renderPlayback() } // --------------------------------------------------------------------------- // The whole set side by side +/** The size a typical asset of the set is shown at when it opens: a 16 px sprite at 6×, a 32 px one at 3×. */ +const SET_TYPICAL = 96 +/** A column holds such a sprite and the tile's padding. */ +const SET_COLUMN = 112 +const SET_GAP = 10 +/** Rows this fine let tiles of any height interlock, and still line up. */ +const SET_ROW = 16 +const SET_PADDING = 8 +/** A tile's foot and its padding, under the art. */ +const SET_FOOT = 42 +/** The room a tile's art has at the set's own zoom: enough to see it, so one big picture does not push the rest out of view. */ +const SET_TALL = 192 +const SET_ACROSS = 3 + +/** The zoom the set's art asks for: its median asset at about `SET_TYPICAL`. One outlier does not move it. */ +const fitSetZoom = (): number => { + const sides = state.library.flatMap((entry) => { + const frame = entry.frames[0] + return frame === undefined ? [] : [Math.max(frame.w, frame.h)] + }) + const median = [...sides].sort((a, b) => a - b)[sides.length >> 1] ?? SET_TYPICAL + return Math.max(1, Math.min(8, Math.round(SET_TYPICAL / median))) +} + +/** The grid's columns in the room the stage has now. */ +const setColumns = (): { readonly count: number; readonly width: number } => { + const style = getComputedStyle(content) + const room = Math.min(980, content.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)) - 24 + const count = Math.max(2, Math.floor((room + SET_GAP) / (SET_COLUMN + SET_GAP))) + return { count, width: (room - SET_GAP * (count - 1)) / count } +} + +let setColumnsDrawn = 0 + const renderSet = (): void => { + if (state.setFit) state.setZoom = fitSetZoom() renderPlayback() const zoom = state.setZoom + const columns = setColumns() + setColumnsDrawn = columns.count + const spanned = (count: number): number => count * columns.width + (count - 1) * SET_GAP + const wholeWidth = spanned(columns.count) - SET_PADDING * 2 + // A zoom the person picked past the set's own gives the art room to grow with it; 1× they picked is the art's own size, as far as the grid is wide. + const asItIs = !state.setFit && zoom === 1 + const grown = Math.max(1, zoom / fitSetZoom()) + const mostAcross = asItIs ? wholeWidth : Math.min(wholeWidth, (spanned(Math.min(SET_ACROSS, columns.count)) - SET_PADDING * 2) * grown) + const mostDown = asItIs ? Number.POSITIVE_INFINITY : SET_TALL * grown + /** The set's zoom where the art fits; else the largest whole zoom that does, so its pixels stay even; and only a picture too big at 1× goes under it. */ + const scaleOf = (frame: { readonly w: number; readonly h: number }): number => { + const fits = Math.min(mostAcross / frame.w, mostDown / frame.h) + return fits >= zoom ? zoom : fits >= 1 ? Math.floor(fits) : fits + } const entries = visible().filter((entry) => !state.onlyPicked || state.picked.has(entry.id)) const [a] = backdropColors() const folders = new Set(entries.map((entry) => entry.folder)) @@ -865,27 +950,36 @@ const renderSet = (): void => { const tile = (entry: Entry): HTMLElement => { const frame = entry.frames[animationsOf(entry)[0]!.frames[0] ?? 0] const isPicked = state.picked.has(entry.id) + const scale = frame === undefined ? zoom : scaleOf(frame) + const artWidth = (frame?.w ?? 24) * scale + const artHeight = (frame?.h ?? 24) * scale + const across = Math.max(1, Math.min(columns.count, Math.ceil((artWidth + SET_PADDING * 2 + SET_GAP) / (columns.width + SET_GAP)))) + const down = Math.ceil((Math.max(artHeight, 64) + SET_FOOT + SET_GAP) / SET_ROW) const art = entry.problem !== undefined || frame === undefined ? h("div", { class: "pixel-set-broken", title: entry.problem }, icon("alert")) : h( "div", - { class: "pixel-set-art", attrs: { style: `width:${frame.w * zoom}px;height:${frame.h * zoom}px` } }, + { class: `pixel-set-art${scale < 1 ? " shrunk" : ""}`, attrs: { style: `width:${artWidth}px;height:${artHeight}px` } }, h("img", { attrs: { src: store.picture(entry.id, entry.version), alt: entry.name, loading: "lazy", decoding: "async", - width: entry.width * zoom, - height: entry.height * zoom, - style: `width:${entry.width * zoom}px;height:${entry.height * zoom}px;margin-left:${-frame.x * zoom}px;margin-top:${-frame.y * zoom}px` + width: Math.round(entry.width * scale), + height: Math.round(entry.height * scale), + style: `width:${entry.width * scale}px;height:${entry.height * scale}px;margin-left:${-frame.x * scale}px;margin-top:${-frame.y * scale}px` } }) ) return h( "div", - { class: `pixel-set-tile${entry.id === state.current ? " on" : ""}${isPicked ? " picked" : ""}`, title: entry.id, attrs: { "data-asset": entry.id } }, + { + class: `pixel-set-tile${entry.id === state.current ? " on" : ""}${isPicked ? " picked" : ""}`, + title: `${entry.id} · ${frame?.w ?? 0}×${frame?.h ?? 0}`, + attrs: { "data-asset": entry.id, style: `grid-column:span ${across};grid-row:span ${down}` } + }, h( "button", { @@ -896,6 +990,8 @@ const renderSet = (): void => { }, art ), + // Not at the set's scale: said on the tile, so its size is not read against the rest. + frame !== undefined && scale !== zoom && h("span", { class: "pixel-set-scale", title: "Too big for the grid at the set's scale" }, `${scale >= 1 ? scale : scale.toFixed(2)}×`), h( "button", { @@ -922,17 +1018,17 @@ const renderSet = (): void => { "div", { class: "sound-head" }, h("div", { class: "sound-title" }, h("h1", {}, "The whole set"), pill(`${entries.length} shown`), pill(`${zoom}×`)), - h("p", { class: "sound-about" }, "Every asset at its own size, side by side, over one backdrop: does it read as one game? Pick some to compare them alone or approve them together. A sheet shows its first frame.") + h("p", { class: "sound-about" }, "Every asset at one scale, over one backdrop: does it read as one game? A picture too big for the grid is smaller and says its zoom. Pick some to compare or approve them together.") ), - picked.length > 0 && - h( + // Always here, so picking the first asset moves nothing on the page. + h( "div", { class: "pixel-picked" }, - h("span", {}, `${picked.length} picked`), + h("span", {}, picked.length === 0 ? "None picked" : `${picked.length} picked`), button(state.onlyPicked ? "Show all" : "Compare them", () => { state.onlyPicked = !state.onlyPicked renderStage() - }, { small: true, variant: "secondary", title: "Only the picked ones, side by side" }), + }, { small: true, variant: "secondary", disabled: picked.length === 0, title: "Only the picked ones, side by side" }), button("Approve them", () => { for (const id of picked) { const entry = entryOf(id) @@ -940,16 +1036,16 @@ const renderSet = (): void => { } reviewChanged() say(`Approved ${picked.length}: they look right as they are now.`) - }, { small: true, variant: "primary", icon: "check" }), + }, { small: true, variant: "primary", icon: "check", disabled: picked.length === 0 }), button("Clear", () => { state.picked.clear() state.onlyPicked = false renderStage() - }, { small: true, variant: "ghost" }) + }, { small: true, variant: "ghost", disabled: picked.length === 0 }) ), entries.length === 0 && h("p", { class: "hint" }, "Nothing here."), [...byFolder.entries()].map(([folder, inside]) => - h("section", { class: "pixel-set-folder" }, folders.size > 1 && h("h3", { class: "pixel-folder" }, folder), h("div", { class: "pixel-set", attrs: { style: `--backdrop:${a}` } }, inside.map(tile))) + h("section", { class: "pixel-set-folder" }, folders.size > 1 && h("h3", { class: "pixel-folder" }, folder), h("div", { class: "pixel-set", attrs: { style: `--backdrop:${a};--set-columns:${columns.count}` } }, inside.map(tile))) ) ) } @@ -959,14 +1055,34 @@ const renderSet = (): void => { const setZoom = (zoom: number): void => { if (state.view === "set") { + state.setFit = false state.setZoom = Math.max(1, Math.min(8, zoom)) return renderStage() } const entry = entryOf(state.current) + state.fit = false state.zoom = Math.max(1, Math.min(entry === undefined ? 32 : maxZoom(entry), zoom)) renderStage() } +/** One step: whole zooms up and down; under 1×, only the fit a picture too big for the stage has. */ +const zoomBy = (by: 1 | -1): void => { + const zoom = state.view === "set" ? state.setZoom : state.zoom + const entry = entryOf(state.current) + if (by < 0 && zoom <= 1) { + if (state.view === "asset" && entry !== undefined && entry.problem === undefined && fitZoom(entry) < zoom) fitAgain() + return + } + setZoom(by > 0 ? Math.floor(zoom) + 1 : Math.ceil(zoom) - 1) +} + +/** Back to the zoom that fits: each asset's, or the whole set's. */ +const fitAgain = (): void => { + if (state.view === "set") state.setFit = true + else state.fit = true + renderStage() +} + const toggle = (): void => { const entry = entryOf(state.current) if (entry === undefined || entry.animations.length === 0 || state.view !== "asset") return @@ -993,6 +1109,7 @@ const renderPlayback = (): void => { const sheet = state.view === "asset" && entry !== undefined && entry.problem === undefined && entry.animations.length > 0 const zoom = state.view === "set" ? state.setZoom : state.zoom const top = state.view === "set" ? 8 : entry === undefined ? 32 : maxZoom(entry) + const least = state.view === "asset" && entry !== undefined && entry.problem === undefined ? Math.min(1, fitZoom(entry)) : 1 replace( playback, sheet && [ @@ -1004,10 +1121,11 @@ const renderPlayback = (): void => { ], state.view === "asset" && entry !== undefined && entry.animations.length === 0 && screen?.counter, state.view === "asset" && entry !== undefined && entry.animations.length === 0 && h("span", { class: "divider" }), - button("Zoom out", () => setZoom(zoom - 1), { variant: "ghost", icon: "minus", iconOnly: true, disabled: zoom <= 1, title: "Zoom out (−)" }), - h("span", { class: "zoom-value", attrs: { "aria-label": "Zoom" } }, `${zoom}×`), - button("Zoom in", () => setZoom(zoom + 1), { variant: "ghost", icon: "plus", iconOnly: true, disabled: zoom >= top, title: "Zoom in (+)" }), - zoom !== 1 && button("1×", () => setZoom(1), { variant: "ghost", small: true, title: "Back to its own size (0)" }) + button("Zoom out", () => zoomBy(-1), { variant: "ghost", icon: "minus", iconOnly: true, disabled: zoom <= least, title: "Zoom out (−)" }), + h("span", { class: "zoom-value", attrs: { "aria-label": "Zoom" } }, zoomWords(zoom)), + button("Zoom in", () => zoomBy(1), { variant: "ghost", icon: "plus", iconOnly: true, disabled: zoom >= top, title: "Zoom in (+)" }), + button("1×", () => setZoom(1), { variant: "ghost", small: true, disabled: zoom === 1 && !(state.view === "set" ? state.setFit : state.fit), title: "Its own size (0)" }), + button("Fit", fitAgain, { variant: "ghost", small: true, disabled: state.view === "set" ? state.setFit : state.fit, title: "As large as fits (F)" }) ) renderCounter() } @@ -1298,7 +1416,7 @@ const renderViewPanel = (): void => { redraw( viewBox, section( - { id: "pixel-view", title: "View", help: "What the art is drawn over, and how. The backdrop is in squares of 4 art pixels, so the pixel size shows; pick one near the game's own ground. Repeat draws the frame three by three, to see a tile's seams.", onToggle: renderViewPanel }, + { id: "pixel-view", title: "View", closed: true, help: "What the art is drawn over, and how. The backdrop is in squares of 4 art pixels, so the pixel size shows; pick one near the game's own ground. Repeat draws the frame three by three, to see a tile's seams.", onToggle: renderViewPanel }, group( "Backdrop", h( @@ -1344,7 +1462,7 @@ const viewChanged = (): void => { const setPanel = (): HTMLElement => section( - { id: "pixel-set", title: "The whole set", help: "Every asset at its own size, side by side, to judge the art as one game: its sizes, outlines, palette, and detail should agree. Pick some (the check under each) to compare them alone or approve them together; open one to comment on it.", onToggle: renderReview }, + { id: "pixel-set", title: "The whole set", help: "Every asset at one scale, side by side, to judge the art as one game: its sizes, outlines, palette, and detail should agree. A sheet shows its first frame. Pick some (the check on each) to compare them alone or approve them together; open one to comment on it.", onToggle: renderReview }, hint(`${visible().length} shown at ${state.setZoom}×. Open one to comment on its pixels.`) ) @@ -1367,7 +1485,7 @@ const renderAbout = (): void => h("code", {}, "pixel.review.json"), " at the game's root. Fixing pixels by hand belongs on the pixel fixer's page." ), - h("p", { class: "hint" }, "Keys: ↑ ↓ change asset, Space plays, ← → step frames, + − zoom, 0 back to 1×, V the whole set and back, Esc drops a comment being drawn.") + h("p", { class: "hint" }, "Keys: ↑ ↓ change asset, Space plays, ← → step frames, + − zoom, 0 back to 1×, F as large as fits, V the whole set and back, Esc drops a comment being drawn.") ) ) @@ -1444,11 +1562,13 @@ window.addEventListener("keydown", (event) => { event.preventDefault() stepFrame(event.key === "ArrowRight" ? 1 : -1) } else if (event.key === "+" || event.key === "=") { - setZoom((state.view === "set" ? state.setZoom : state.zoom) + 1) + zoomBy(1) } else if (event.key === "-" || event.key === "_") { - setZoom((state.view === "set" ? state.setZoom : state.zoom) - 1) + zoomBy(-1) } else if (event.key === "0") { setZoom(1) + } else if (event.key === "f" || event.key === "F") { + fitAgain() } else if (event.key === "v" || event.key === "V") { // Not Tab: that key belongs to moving through the page's controls. setView(state.view === "asset" ? "set" : "asset") @@ -1460,7 +1580,21 @@ window.addEventListener("keydown", (event) => { } }) -window.addEventListener("resize", () => repaint()) +const sizeChanged = (): void => { + if (state.view === "set") { + if (setColumns().count !== setColumnsDrawn) renderStage() + return + } + const entry = entryOf(state.current) + if (state.fit && entry !== undefined && entry.problem === undefined && fitZoom(entry) !== state.zoom) renderStage() + else repaint() +} + +window.addEventListener("resize", sizeChanged) +// The stage's own size, not only the window's: a browser may first lay it out before it has its full height, and the title above the art may wrap. Next frame, since drawing during the observer's own pass would report again. +const stageWatch = new ResizeObserver(() => requestAnimationFrame(sizeChanged)) +stageWatch.observe(content) +stageWatch.observe(head) /** Draws the page and opens the art from where `chosen` keeps it; follows the agent's changes. */ export const start = (chosen: Store): void => { diff --git a/packages/tools/src/pixel/review/style.css b/packages/tools/src/pixel/review/style.css index 9bf4df8a..4b7939e8 100644 --- a/packages/tools/src/pixel/review/style.css +++ b/packages/tools/src/pixel/review/style.css @@ -133,12 +133,19 @@ } .pixel-counter { - min-width: 0; + /* Room for most sizes, so the zoom buttons stay where they are from asset to asset. */ + min-width: 92px; padding: 0 6px; + text-align: center; white-space: nowrap; font-variant-numeric: tabular-nums; } +.pixel-stage .zoom-value { + min-width: 34px; + text-align: center; +} + .pixel-stage .playback { flex-wrap: wrap; justify-content: center; @@ -156,11 +163,16 @@ cursor: pointer; } +.topbar-center .segmented { + /* Their text would line them up otherwise, and one has icons: boxes line up instead. */ + vertical-align: top; +} + .topbar-center .pixel-views { margin-left: 8px; } -/* The whole set: every asset at its own size, over one backdrop. */ +/* The whole set: every asset at one scale, on a grid by size, over one backdrop. */ .pixel-set-folder + .pixel-set-folder { margin-top: 14px; @@ -172,11 +184,13 @@ /* One backdrop under the whole set, so the art reads together, as on one screen. */ .pixel-set { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 10px 12px; - padding: 12px; + display: grid; + grid-template-columns: repeat(var(--set-columns, 4), minmax(0, 1fr)); + /* Fine rows, each tile spanning what it needs: tiles of any height interlock, and dense fills the gaps. */ + grid-auto-rows: 16px; + grid-auto-flow: dense; + column-gap: 10px; + padding: 12px 12px 2px; border-radius: var(--radius-lg); background: var(--backdrop, #1a1c2c); } @@ -185,11 +199,15 @@ position: relative; display: flex; flex-direction: column; - align-items: center; - gap: 4px; - max-width: 100%; - padding: 3px; - border-radius: 4px; + min-width: 0; + margin-bottom: 10px; + padding: 8px; + border-radius: 6px; + background: rgba(128, 128, 128, 0.1); +} + +.pixel-set-tile:hover { + background: rgba(128, 128, 128, 0.18); } .pixel-set-tile.on { @@ -202,9 +220,11 @@ .pixel-set-open { display: grid; + flex: 1; place-items: center; - max-width: 100%; - overflow: auto; + width: 100%; + min-height: 0; + overflow: hidden; padding: 0; border: 0; background: transparent; @@ -221,6 +241,25 @@ image-rendering: pixelated; } +.pixel-set-art.shrunk img { + image-rendering: auto; +} + +/* A picture not at the set's scale says its own. */ +.pixel-set-scale { + position: absolute; + top: 4px; + left: 4px; + padding: 0 4px; + border-radius: 3px; + background: rgba(15, 16, 20, 0.75); + color: #d4d4da; + font-family: var(--mono); + font-size: 10px; + line-height: 15px; + pointer-events: none; +} + .pixel-set-broken { display: grid; place-items: center; @@ -233,8 +272,9 @@ display: flex; align-items: center; gap: 4px; + flex: none; min-width: 0; - max-width: 112px; + margin-top: 6px; padding: 1px 4px; border-radius: 4px; background: rgba(15, 16, 20, 0.6); @@ -242,6 +282,7 @@ } .pixel-set-name { + flex: 1; min-width: 0; overflow: hidden; color: #d4d4da; @@ -255,6 +296,13 @@ height: 7px; } +/* A flag holds only its icon: no line of text around it to push it off center. */ +.pixel-list .sound-flag, +.pixel-set-foot .sound-flag { + display: inline-flex; + flex: none; +} + .pixel-set-foot .sound-flag .icon { width: 11px; height: 11px; @@ -303,7 +351,8 @@ .pixel-picked { position: sticky; - top: 0; + /* Sticky measures from inside the stage's padding: this leaves 8px under the top bar. */ + top: -20px; z-index: 1; display: flex; flex-wrap: wrap; @@ -312,7 +361,8 @@ margin-bottom: 14px; padding: 8px 10px; border-radius: var(--radius-lg); - background: var(--floating); + /* Solid: tiles scroll under it, and their names would read through. */ + background: #1f2128; box-shadow: var(--shadow-float); color: #ececef; font-size: 12.5px; @@ -322,13 +372,24 @@ margin-right: auto; } +/* "Compare them" and "Show all" take the same room. */ +.pixel-picked > button:first-of-type { + min-width: 104px; +} + @media (max-width: 760px) { .topbar-center .pixel-views { margin-left: 0; } - .pixel-set-foot { - max-width: 96px; + /* The stage's padding is 16px here. */ + .pixel-picked { + top: -8px; + } + + /* The count on a row of its own: the buttons never wrap differently as it changes. */ + .pixel-picked > span { + flex-basis: 100%; } /* "2 of 7 · 125 ms": the playback bar stays one row on a phone. */