Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions skills/atet/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ choose the narrowest workflow that can produce and verify that result.
- 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,
Expand Down
8 changes: 8 additions & 0 deletions skills/atet/references/gateway-media.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
139 changes: 139 additions & 0 deletions skills/atet/references/rubber-stamp-field-notes.md
Original file line number Diff line number Diff line change
@@ -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-<place-slug>-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.
229 changes: 229 additions & 0 deletions skills/atet/scripts/compose-rubber-stamp-field-note.ts
Original file line number Diff line number Diff line change
@@ -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 <path> --stamp <path> --output <path> \\
--place <NAME> --number <NN> --keywords "a / b / c" --year <YYYY> \\
[--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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}

async function paperPanel(width: number, height: number): Promise<Buffer> {
// 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(`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<filter id="grain">
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.72 0 0 0 0 0.68 0 0 0 0 0.58 0 0 0 0.12 0"/>
</filter>
<rect width="100%" height="100%" fill="#ece4d2"/>
<rect width="100%" height="100%" filter="url(#grain)"/>
</svg>`);

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<Buffer> {
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(`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<style>
.t {
font-family: "DejaVu Sans Mono", monospace;
font-size: ${fontSize}px;
fill: #1a1a1a;
letter-spacing: 0.5px;
}
</style>
${lines
.map(
(line, index) =>
`<text class="t" x="0" y="${startY + index * lineGap}">${escapeXml(line)}</text>`,
)
.join("\n ")}
</svg>`);
return sharp(textSvg).png().toBuffer();
}

async function main(): Promise<void> {
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();