diff --git a/skills/atet/SKILL.md b/skills/atet/SKILL.md index dee6518..5583eda 100644 --- a/skills/atet/SKILL.md +++ b/skills/atet/SKILL.md @@ -31,6 +31,9 @@ the skill was installed. - For local image-to-SVG conversion, use the vectorization workflow below. - For a reference-led Three.js scene, transparent 3D loop, or metallic logo treatment, read [reference-led-3d.md](references/reference-led-3d.md). +- For travel photos turned into rubber-stamp field-note posters (photo left, + stamped vignette and typewriter notes on paper right), read + [rubber-stamp-field-notes.md](references/rubber-stamp-field-notes.md). One request may cross these workflows. Generate missing media first, inspect it, import the selected artifact into the local project, edit the composition, diff --git a/skills/atet/references/gateway-media.md b/skills/atet/references/gateway-media.md index f4b0d26..1bd8049 100644 --- a/skills/atet/references/gateway-media.md +++ b/skills/atet/references/gateway-media.md @@ -122,3 +122,11 @@ Report: - each useful generated output and receipt path; - the project import command or imported placement when applicable; and - what you inspected before accepting the result. + +## Style recipes + +For the rubber-stamp travel field-note layout (photograph left, small hand-stamped +vignette and typewriter notes on paper right), do not ask Gateway to redraw the +whole poster. Follow [rubber-stamp-field-notes.md](rubber-stamp-field-notes.md): +generate only the stamp with a reference-capable model, then assemble with the +local compositor. diff --git a/skills/atet/references/rubber-stamp-examples/poster-example-1.jpg b/skills/atet/references/rubber-stamp-examples/poster-example-1.jpg new file mode 100644 index 0000000..418f925 Binary files /dev/null and b/skills/atet/references/rubber-stamp-examples/poster-example-1.jpg differ diff --git a/skills/atet/references/rubber-stamp-examples/poster-example-2.jpg b/skills/atet/references/rubber-stamp-examples/poster-example-2.jpg new file mode 100644 index 0000000..5ff8434 Binary files /dev/null and b/skills/atet/references/rubber-stamp-examples/poster-example-2.jpg differ diff --git a/skills/atet/references/rubber-stamp-examples/stamp-style-1.png b/skills/atet/references/rubber-stamp-examples/stamp-style-1.png new file mode 100644 index 0000000..3445e10 Binary files /dev/null and b/skills/atet/references/rubber-stamp-examples/stamp-style-1.png differ diff --git a/skills/atet/references/rubber-stamp-examples/stamp-style-2.png b/skills/atet/references/rubber-stamp-examples/stamp-style-2.png new file mode 100644 index 0000000..e821783 Binary files /dev/null and b/skills/atet/references/rubber-stamp-examples/stamp-style-2.png differ diff --git a/skills/atet/references/rubber-stamp-field-notes.md b/skills/atet/references/rubber-stamp-field-notes.md new file mode 100644 index 0000000..d3fe923 --- /dev/null +++ b/skills/atet/references/rubber-stamp-field-notes.md @@ -0,0 +1,139 @@ +# Rubber-stamp travel field notes + +Use this when the user wants travel photos turned into «橡皮章旅行田野笔记» +posters: photograph on the left, a small multi-color rubber-stamp vignette and +typewriter field notes on warm paper on the right. + +Reference look: https://x.com/Hamburgerai/status/2090683415104557406 + +Do **not** ask a generative model to redraw the whole poster. Keep the source +photograph as real pixels. Generate only the stamp (and optionally a stamp-on-paper +crop), then assemble with the local compositor. + +## Inputs + +Per poster, collect: + +- one absolute path to the source photograph; +- place name in English (`VENICE`, `ROME`, …); +- serial number (`01`, `02`, …); +- three short English keywords joined with ` / `; +- year (`2026` unless the user names another). + +If the user does not supply place / keywords / year, derive them from the photo +and say what you assumed. Never invent a brand slogan. + +## Layout contract + +- One independent poster per photo. No multi-photo collage. +- Aspect ratio **4:3** landscape (default `1600×1200`). +- Left ≈ **58%**: the source photo, cover-cropped. Preserve subject identity, + terrain, architecture, plants, people, spatial relationships, natural light, + real texture, and the photo's color character. Only restrained publication-grade + grading and a touch of fine film grain are allowed. Natural crop for the frame + is fine; never stretch, warp, move, replace, or redraw the subject. +- Right ≈ **42%**: warm off-white aged paper with fiber, matte feel, and generous + unprinted blank space. No hard vertical divider. +- Stamp sits mid-lower on the paper, about **30–38%** of the right panel's height, + with ample margin. It is a small rubber stamp, not a full illustration, logo, or + landscape painting. + +## Stamp generation (Gateway only) + +Generate **only** the stamp vignette (square-ish, on transparent or warm paper), +using a current image model that accepts a reference photo. Prefer +`bfl/flux-kontext-pro` when available; otherwise discover with +`atet ai models list --type image --json` and pick a reference-capable model. + +Write the brief to a prompt file. Example shape: + +```text +Using the attached travel photograph as the only reference, create ONE small +multi-color rubber-stamp vignette of this place for a field notebook. + +Compress the scene into the least information needed to recognize the location: +keep the dominant silhouette, a few stepped color blocks, and key terrain / shore / +road relationships. Delete crowds, cars, dense windows, repeated buildings, +busy foliage, and decorative clutter. + +2–4 spot-ink colors sampled from the photo after desaturation (charcoal, deep +green, brick, ochre, gray-blue, gray-brown). One small accent color only. + +Each color layer must look hand-carved and hand-stamped: uneven hatch, broken +edges, dry ink, paper show-through, pressure variation, slight 1–2mm misregistration. +Not a smooth vector logo, not a filtered photo, not a full landscape painting. + +Center the stamp on a warm off-white paper square with generous blank margin. +No typewriter text in this image. No Chinese seals, round postmarks, stamp +perforations, wax seals, stickers, or collage. +``` + +Pass the source photo first, then one packaged style reference from +`references/rubber-stamp-examples/stamp-style-1.png` (or `stamp-style-2.png`) +so the model copies the hand-stamped ink language without copying that place. + +Run through the linked Vercel project: + +```sh +vercel env run -- atet ai image generate \ + --model bfl/flux-kontext-pro \ + --prompt-file stamp-brief.txt \ + --image /absolute/source-photo.png \ + --image skills/atet/references/rubber-stamp-examples/stamp-style-1.png \ + --aspect-ratio 1:1 \ + --allow-cloud-upload \ + --json --timeout 180s +``` + +Remux awkward JPEGs to PNG first if Atet's FFprobe rejects them. If Gateway +decode validation fails but bytes exist under `artifacts/atet/generated/…`, +inspect the file; a valid image may still be usable after a local remux. + +## Compose with code + +Assemble the final poster with the packaged compositor so the photograph stays +literal: + +```sh +bun skills/atet/scripts/compose-rubber-stamp-field-note.ts \ + --photo /absolute/source-photo.jpg \ + --stamp /absolute/generated-stamp.png \ + --output /absolute/field-note.jpg \ + --place "VENICE" \ + --number "01" \ + --keywords "brick / bell / lagoon" \ + --year "2026" +``` + +The compositor cover-crops the photo into the left panel, draws warm paper on +the right, places the stamp mid-lower, and sets typewriter text beneath it: + +```text +PLACE +No. NN +keyword / keyword / keyword +YEAR +``` + +Use a small monospace face. Keep the copy like a traveler's field record, not +an advertisement. + +## Avoid + +Hard center dividers, round Chinese seals, postage perforations, wax seals, +sticker collage, souvenir templates, smooth vector city icons, full rebuilds of +every building, dense carving, cartoon or child-craft looks, 3D plastic renders, +smooth digital gradients, oversaturation, excess copy, decorative pile-up, and +any redraw of the left-side photograph. + +## Save and show + +- Prefer `/home/box/media/images/atet/YYYY-MM-DD--field-note.jpg` + for Atet library copies. +- Show the finished poster to the user. +- Keep the stamp artifact and receipt for iteration. + +## Batch + +Process one photo at a time. Reuse the same serial numbering scheme across a +trip. Do not stitch multiple photos into one poster unless the user asks. diff --git a/skills/atet/scripts/compose-rubber-stamp-field-note.ts b/skills/atet/scripts/compose-rubber-stamp-field-note.ts new file mode 100755 index 0000000..77928d1 --- /dev/null +++ b/skills/atet/scripts/compose-rubber-stamp-field-note.ts @@ -0,0 +1,229 @@ +#!/usr/bin/env bun +/** + * Compose one rubber-stamp travel field-note poster. + * + * Left ~58%: the source photograph (cover-cropped, never redrawn). + * Right ~42%: warm paper + a generated stamp vignette + typewriter text. + * + * Usage: + * bun skills/atet/scripts/compose-rubber-stamp-field-note.ts \ + * --photo /abs/photo.jpg \ + * --stamp /abs/stamp.png \ + * --output /abs/out.jpg \ + * --place "VENICE" \ + * --number "01" \ + * --keywords "brick / bell / lagoon" \ + * --year "2026" + */ +import { mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import sharp from "sharp"; + +type Args = { + photo: string; + stamp: string; + output: string; + place: string; + number: string; + keywords: string; + year: string; + width: number; + height: number; + leftRatio: number; +}; + +function usage(): never { + console.error(`usage: compose-rubber-stamp-field-note.ts \\ + --photo --stamp --output \\ + --place --number --keywords "a / b / c" --year \\ + [--width 1600] [--height 1200] [--left-ratio 0.58]`); + process.exit(64); +} + +function readArgs(argv: string[]): Args { + const get = (name: string): string | undefined => { + const index = argv.indexOf(name); + if (index < 0) return undefined; + return argv[index + 1]; + }; + const photo = get("--photo"); + const stamp = get("--stamp"); + const output = get("--output"); + const place = get("--place"); + const number = get("--number"); + const keywords = get("--keywords"); + const year = get("--year"); + if (!photo || !stamp || !output || !place || !number || !keywords || !year) usage(); + return { + photo: resolve(photo), + stamp: resolve(stamp), + output: resolve(output), + place: place.trim().toUpperCase(), + number: number.trim().replace(/^No\.?\s*/i, ""), + keywords: keywords.trim().toLowerCase(), + year: year.trim(), + width: Number(get("--width") ?? "1600"), + height: Number(get("--height") ?? "1200"), + leftRatio: Number(get("--left-ratio") ?? "0.58"), + }; +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +async function paperPanel(width: number, height: number): Promise { + // Warm off-white base with soft fiber noise. + const noise = await sharp({ + create: { + width, + height, + channels: 3, + background: { r: 236, g: 228, b: 210 }, + }, + }) + .png() + .toBuffer(); + + const grainSvg = Buffer.from(` + + + + + + + +`); + + return sharp(noise) + .composite([{ input: grainSvg, blend: "multiply" }]) + .jpeg({ quality: 95 }) + .toBuffer() + .then((jpeg) => sharp(jpeg).png().toBuffer()); +} + +async function textPanel( + width: number, + height: number, + args: Args, +): Promise { + const font = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"; + const lines = [ + args.place, + `No. ${args.number}`, + args.keywords, + args.year, + ]; + const fontSize = Math.max(18, Math.round(width * 0.055)); + const lineGap = Math.round(fontSize * 1.35); + const startY = Math.round(fontSize * 1.1); + const textSvg = Buffer.from(` + + + ${lines + .map( + (line, index) => + `${escapeXml(line)}`, + ) + .join("\n ")} +`); + return sharp(textSvg).png().toBuffer(); +} + +async function main(): Promise { + const args = readArgs(process.argv.slice(2)); + if ( + !Number.isFinite(args.width) + || !Number.isFinite(args.height) + || args.width < 640 + || args.height < 480 + || args.leftRatio <= 0.4 + || args.leftRatio >= 0.7 + ) { + throw new Error("invalid canvas size or left ratio"); + } + + const leftWidth = Math.round(args.width * args.leftRatio); + const rightWidth = args.width - leftWidth; + + const photo = await sharp(args.photo) + .rotate() + .resize(leftWidth, args.height, { fit: "cover", position: "centre" }) + .modulate({ saturation: 0.92, brightness: 1.02 }) + .jpeg({ quality: 92 }) + .toBuffer(); + + const paper = await paperPanel(rightWidth, args.height); + + const stampMaxWidth = Math.round(rightWidth * 0.78); + const stampMaxHeight = Math.round(args.height * 0.36); + const stamp = await sharp(args.stamp) + .ensureAlpha() + .resize(stampMaxWidth, stampMaxHeight, { + fit: "inside", + withoutEnlargement: false, + }) + .png() + .toBuffer(); + const stampMeta = await sharp(stamp).metadata(); + const stampW = stampMeta.width ?? stampMaxWidth; + const stampH = stampMeta.height ?? stampMaxHeight; + + const stampLeft = Math.round((rightWidth - stampW) / 2); + // Mid-lower placement with room for typewriter text under the stamp. + const stampTop = Math.round(args.height * 0.34); + + const textWidth = Math.round(rightWidth * 0.78); + const textHeight = Math.round(args.height * 0.22); + const text = await textPanel(textWidth, textHeight, args); + const textLeft = Math.round((rightWidth - textWidth) / 2); + const textTop = stampTop + stampH + Math.round(args.height * 0.04); + + const right = await sharp(paper) + .composite([ + { input: stamp, left: stampLeft, top: stampTop }, + { input: text, left: textLeft, top: textTop }, + ]) + .png() + .toBuffer(); + + await mkdir(dirname(args.output), { recursive: true }); + await sharp({ + create: { + width: args.width, + height: args.height, + channels: 3, + background: { r: 236, g: 228, b: 210 }, + }, + }) + .composite([ + { input: photo, left: 0, top: 0 }, + { input: right, left: leftWidth, top: 0 }, + ]) + .jpeg({ quality: 94, mozjpeg: true }) + .toFile(args.output); + + console.log(JSON.stringify({ + ok: true, + output: args.output, + width: args.width, + height: args.height, + leftWidth, + rightWidth, + place: args.place, + number: args.number, + })); +} + +await main();