Compare commits

..
2 Commits
Author SHA1 Message Date
tillandClaude Opus 5 0798eb7438 Astro 7, and stop a print-only instance calling a route it has switched off
CI / check (push) Has been cancelled
The critical advisory in the dependency tree (GHSA-26w7-cxv4-gfx2, remote code
execution through AVIF image optimisation) has no fix inside Astro 5, so this
moves to Astro 7.3.2 with @astrojs/node 11 and current sharp. `npm audit` now
reports nothing. The only code the jump needed: `csp` graduated out of
`experimental`, and sharp's `Sharp` type is a named export now.

Also fixed, both found by testing with every module switched on rather than
only the print-only default:

- `src/pages/index.astro` passed `dict` to the studio island without ever
  defining it, so the studio page returned 500 on any instance with the AI
  module on. The print-only default redirects away from that page, which is
  why the earlier run never hit it.
- The print studio fetched `/api/delivery-targets` even with the delivery
  module off. The route answered 404 exactly as designed, but it left a red
  line in the console of every print-only instance, which reads like a fault.
  It now asks only when there is a module to ask, and hides the card otherwise.

Plus: the first-run box sizes itself to its content instead of miscounting
padding, and the README's counts are recounted rather than remembered.

Verified against a live instance on a fresh database, in both module sets:
sign-in, module gating, the German interface, an eight-up passport sheet and
its PDF. 124 tests, type check, build and secret scan all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
2026-09-15 15:23:42 +00:00
tillandClaude Opus 5 4ff9f8b7a0 Fix what an independent review of the published repo turned up
- CI pinned Node 20, but `node --test` with type stripping needs 22.6, so the
  workflow could never have gone green. CI and `engines` now say 22.6.
- `docker compose up -d` failed on the documented path: the compose files
  require DB_PASSWORD and .env.example never mentioned it. Documented, and the
  README no longer claims "three secrets".
- The secret scanner treated any comment line as safe, so a pasted `.env`
  fragment — the usual way a key gets committed — slipped through. Verified
  against planted keys before and after.
- Module pages were not guarded: with KLARBILD_MODULES=print a signed-in user
  could still open /shares, /library and /queue and read about features the
  instance does not have. They now redirect, matching the API's 404 behaviour.
- German left in the English build: `bild.<ext>` as a download fallback, and
  31 t() keys with no German at all — the whole main navigation among them, so
  a German instance rendered its nav in English. Dictionary now covers every
  key in the source (1,067 entries).
- Third-party names removed from the data as well as the labels: the `theframe`
  format id is now `tv169`, and the dead Cricut admin field is gone.
- Dockerfile ignored the committed lockfile; it now copies it and uses `npm ci`.
- Dropped an unused dependency (@aws-sdk/s3-request-presigner — storage.ts
  explains why presigned URLs are not needed), a dead `/g/` entry in the
  unauthenticated path list, and an undocumented N8N_WEBHOOK_URL that nothing
  read. Documented TRUSTED_PROXIES, which the rate limiter does read.
- Migration 004 still had "nas" in its filename; README said A6–A2 where the
  list runs A6–A1; two committed screenshots were referenced nowhere.

124 tests green, type check clean, build clean, secret scan clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
2026-09-15 15:16:51 +00:00
37 changed files with 1804 additions and 3967 deletions

No files matched your search

+13 -7
View File
@@ -2,12 +2,15 @@
# #
# Copy to .env and fill in. Never commit the real file. # Copy to .env and fill in. Never commit the real file.
# Everything below the first block is optional: Klarbild starts, and the print # Everything below the first block is optional: Klarbild starts, and the print
# studio works, with nothing but DATABASE_URL, SESSION_SECRET and ENCRYPTION_KEY. # studio works, with nothing but a database, SESSION_SECRET and ENCRYPTION_KEY.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 1. The minimum — this is all a print-only instance needs # 1. The minimum — this is all a print-only instance needs
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# The compose files build DATABASE_URL from DB_PASSWORD, so with Docker you set
# only the password here. Running from source, set DATABASE_URL yourself instead.
DB_PASSWORD=CHANGEME
DATABASE_URL=postgres://klarbild:CHANGEME@db:5432/klarbild DATABASE_URL=postgres://klarbild:CHANGEME@db:5432/klarbild
# Signs the session cookie. Any long random string. # Signs the session cookie. Any long random string.
@@ -43,6 +46,12 @@ KLARBILD_TZ=Europe/Berlin
PORT=4321 PORT=4321
# How many reverse proxies sit in front of this instance. The rate limiter reads
# the client address that many hops from the right of X-Forwarded-For, so a wrong
# number here lets a client either spoof its address or share someone else's
# limit. One proxy (Caddy, nginx, Traefik, Coolify) is the default.
TRUSTED_PROXIES=1
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 2. First start # 2. First start
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -111,9 +120,6 @@ S3_ACCESS_KEY=
S3_SECRET_KEY= S3_SECRET_KEY=
S3_FORCE_PATH_STYLE=true S3_FORCE_PATH_STYLE=true
# ───────────────────────────────────────────────────────────────────────────── # A webhook that fires when a job finishes — for n8n and the like — is set in the
# 9. Optional # admin area rather than here, because it is a per-instance setting rather than a
# ───────────────────────────────────────────────────────────────────────────── # secret.
# A webhook that gets a POST when a job finishes — for n8n and the like.
N8N_WEBHOOK_URL=
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '22'
cache: npm cache: npm
# `npm ci` needs the lockfile to match package.json exactly, which is the # `npm ci` needs the lockfile to match package.json exactly, which is the
+3
View File
@@ -7,3 +7,6 @@ dist
*.log *.log
uploads/ uploads/
data/ data/
.DS_Store
coverage/
*.tsbuildinfo
+3 -1
View File
@@ -11,7 +11,8 @@ private repository as a personal tool.
default is the print studio alone — no image model, no object store, no bot default is the print studio alone — no image model, no object store, no bot
token, no account anywhere. See `src/lib/modules.ts`. token, no account anywhere. See `src/lib/modules.ts`.
- **English interface**, with German as a full second language. The English - **English interface**, with German as a full second language. The English
sentence is the translation key; 1,038 strings. sentence is the translation key; 1,067 entries, covering every translatable
string in the source.
- **First-run setup.** No built-in accounts. The first boot creates one - **First-run setup.** No built-in accounts. The first boot creates one
administrator from `ADMIN_USERNAME` / `ADMIN_PASSWORD`, or generates a password administrator from `ADMIN_USERNAME` / `ADMIN_PASSWORD`, or generates a password
and prints it once to the log. and prints it once to the log.
@@ -23,6 +24,7 @@ private repository as a personal tool.
- English routes: `/print`, `/library`, `/queue`, `/shares`, `/help`, `/account`, - English routes: `/print`, `/library`, `/queue`, `/shares`, `/help`, `/account`,
`/tour`, `/password`. `/tour`, `/password`.
- AGPL-3.0, CI (build, tests, type check, secret scan), `CONTRIBUTING`, `SECURITY`. - AGPL-3.0, CI (build, tests, type check, secret scan), `CONTRIBUTING`, `SECURITY`.
- Astro 7 and current `sharp`: `npm audit` reports no known vulnerability.
**Carried over from the private tool** **Carried over from the private tool**
+5 -3
View File
@@ -1,4 +1,4 @@
FROM node:20-bookworm-slim FROM node:22-bookworm-slim
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production PORT=4321 HOST=0.0.0.0 ENV NODE_ENV=production PORT=4321 HOST=0.0.0.0
@@ -8,8 +8,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates \ python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY package.json ./ # The lockfile comes first and `npm ci` honours it, so the image is built from
RUN npm install --no-audit --no-fund # the exact versions CI tested — not from whatever `^` resolved to today.
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . . COPY . .
RUN npm run build RUN npm run build
+8 -7
View File
@@ -22,7 +22,7 @@ nothing uploaded.
```bash ```bash
git clone https://github.com/tillheidrich/klarbild.git && cd klarbild git clone https://github.com/tillheidrich/klarbild.git && cd klarbild
cp .env.example .env # fill in three secrets, the file says which cp .env.example .env # four values at the top; the file says which
docker compose up -d docker compose up -d
docker compose logs app # your admin password is printed here, once docker compose logs app # your admin password is printed here, once
``` ```
@@ -42,7 +42,7 @@ output is a PDF laid out in millimetres, not a scaled bitmap, so 30 × 40 cm is
**Sheet layout.** Put several pictures on one sheet at 100 % with crop marks: **Sheet layout.** Put several pictures on one sheet at 100 % with crop marks:
corner marks (0.25 pt, outside the trim) or continuous lines. Equal sizes snap to corner marks (0.25 pt, outside the trim) or continuous lines. Equal sizes snap to
an exact grid; mixed sizes go through a MaxRects packer. Bleed 0–10 mm. 19 paper an exact grid; mixed sizes go through a MaxRects packer. Bleed 0–10 mm. 19 paper
formats from A6 to A2 — including A3+ and photo paper 9 × 13 to 20 × 30, Letter formats from A6 to A1 — including A3+ and photo paper 9 × 13 to 20 × 30, Letter
and Legal — or a custom sheet. and Legal — or a custom sheet.
**Pictures bigger than the sheet, handled three ways.** A 20 × 30 cm print on A4 **Pictures bigger than the sheet, handled three ways.** A 20 × 30 cm print on A4
@@ -112,10 +112,11 @@ Astro 5 (SSR, Node standalone) with React islands · PostgreSQL · `sharp` ·
`pdf-lib` · `pg-boss` · OKLCH design tokens, no Tailwind. Files go to a volume by `pdf-lib` · `pg-boss` · OKLCH design tokens, no Tailwind. Files go to a volume by
default; S3/MinIO is available but not required. default; S3/MinIO is available but not required.
About 13,600 lines across 116 files, 124 tests, 16 forward-only idempotent About 13,700 lines across 117 files, 124 tests, 16 forward-only idempotent
migrations that run themselves on boot. English and German interface, 1,038 migrations that run themselves on boot. English and German interface — 1,067
translated strings; the English sentence is the translation key, so there is no key entries, covering every translatable string in the source. The English sentence is
file to keep in sync. the translation key, so there is no key file to keep in sync and a missing entry
degrades to readable English rather than to `print.sheet.title`.
## Install ## Install
@@ -149,7 +150,7 @@ the two tools worth knowing.
## Contributing ## Contributing
[klarbild-demo.heidrich-digital.de](https://klarbild-demo.heidrich-digital.de) is the product [klarbild-demo.heidrich-digital.de](https://klarbild-demo.heidrich-digital.de) is the product
page; its source is [klarbild-demo](https://git.heidrich-digital.de/till/klarbild-demo). page.
Issues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for Issues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for
how the code is laid out and what the house style is. Security reports go to the how the code is laid out and what the house style is. Security reports go to the
+12 -13
View File
@@ -14,19 +14,18 @@ export default defineConfig({
// Content-Security-Policy with hashes instead of 'unsafe-inline': Astro computes // Content-Security-Policy with hashes instead of 'unsafe-inline': Astro computes
// the hash of every inline script and style it emits, at build time. The remaining // the hash of every inline script and style it emits, at build time. The remaining
// directives are set by the middleware (src/middleware.ts). // directives are set by the middleware (src/middleware.ts).
experimental: { // Graduated out of `experimental` in Astro 7.
csp: { csp: {
directives: [ directives: [
"default-src 'self'", "default-src 'self'",
"img-src 'self' data: blob:", "img-src 'self' data: blob:",
"font-src 'self'", "font-src 'self'",
"connect-src 'self'", "connect-src 'self'",
"object-src 'none'", "object-src 'none'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",
"frame-ancestors 'none'", "frame-ancestors 'none'",
], ],
},
}, },
server: { host: true, port: Number(process.env.PORT) || 4321 }, server: { host: true, port: Number(process.env.PORT) || 4321 },
vite: { vite: {
-1
View File
@@ -38,7 +38,6 @@ services:
OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}" OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}"
TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}" TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}"
DELIVERY_DEFAULT_FOLDER: "${DELIVERY_DEFAULT_FOLDER:-klarbild}" DELIVERY_DEFAULT_FOLDER: "${DELIVERY_DEFAULT_FOLDER:-klarbild}"
N8N_WEBHOOK_URL: "${N8N_WEBHOOK_URL:-}"
# The database wins over these; they only apply until something is entered # The database wins over these; they only apply until something is entered
# under Admin → Email, so a fresh install can send mail straight away. # under Admin → Email, so a fresh install can send mail straight away.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

+1 -1
View File
@@ -32,7 +32,7 @@ own tools) and then calls the Klarbild tools.
- **process_images** — upload and process images (local paths or `data:` URLs). - **process_images** — upload and process images (local paths or `data:` URLs).
Give either `recipeId` (a preset) or `mode` (`each`/`compose`/`generate`) + options: Give either `recipeId` (a preset) or `mode` (`each`/`compose`/`generate`) + options:
`tasks` (`clean`/`cutout`/`format`/`contour`), `prompt_text`, `output_format` `tasks` (`clean`/`cutout`/`format`/`contour`), `prompt_text`, `output_format`
(fixed, like `30x40`/`theframe`, **or custom**, `25x35` / `sticker5`), `output_ext` (fixed, like `30x40`/`tv169`, **or custom**, `25x35` / `sticker5`), `output_ext`
(`png`/`jpg`, omit = global default), `orientation`, `crop_mode`, `contour_mm`, (`png`/`jpg`, omit = global default), `orientation`, `crop_mode`, `contour_mm`,
`delivery_folder`, `delivery`. Returns the job ID. `delivery_folder`, `delivery`. Returns the job ID.
- **job_status** — query the status of a job (including finish time and error messages). - **job_status** — query the status of a job (including finish time and error messages).
+1 -1
View File
@@ -54,7 +54,7 @@ const TOOLS = [
tasks: { type: 'array', items: { type: 'string', enum: ['clean', 'cutout', 'format', 'contour'] }, tasks: { type: 'array', items: { type: 'string', enum: ['clean', 'cutout', 'format', 'contour'] },
description: 'Only for mode=each. Default: ["format"] if output_format is set.' }, description: 'Only for mode=each. Default: ["format"] if output_format is set.' },
prompt_text: { type: 'string', description: 'Description for compose/generate' }, prompt_text: { type: 'string', description: 'Description for compose/generate' },
output_format: { type: 'string', description: 'Fixed: 30x40, A4, theframe, portrait916, keep … OR custom: "25x35" (=25×35 cm) or "sticker5" (=5×5 cm).' }, output_format: { type: 'string', description: 'Fixed: 30x40, A4, tv169, portrait916, keep … OR custom: "25x35" (=25×35 cm) or "sticker5" (=5×5 cm).' },
output_ext: { type: 'string', enum: ['png', 'jpg'], description: 'File format. Omit = global default setting. JPG only without transparency.' }, output_ext: { type: 'string', enum: ['png', 'jpg'], description: 'File format. Omit = global default setting. JPG only without transparency.' },
orientation: { type: 'string', enum: ['portrait', 'landscape'] }, orientation: { type: 'string', enum: ['portrait', 'landscape'] },
crop_mode: { type: 'string', enum: ['crop', 'extend'] }, crop_mode: { type: 'string', enum: ['crop', 'extend'] },
+1627 -3853
View File
File diff suppressed because it is too large. Load diff
+5 -6
View File
@@ -26,7 +26,7 @@
], ],
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20" "node": ">=22.6"
}, },
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
@@ -38,15 +38,14 @@
"scan": "node scripts/secret-scan.mjs" "scan": "node scripts/secret-scan.mjs"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^9.1.3", "@astrojs/node": "^11.1.5",
"@astrojs/react": "^4.2.1", "@astrojs/react": "^6.0.5",
"@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/s3-request-presigner": "^3.700.0",
"@fontsource/space-grotesk": "^5.1.0", "@fontsource/space-grotesk": "^5.1.0",
"@fontsource/space-mono": "^5.1.0", "@fontsource/space-mono": "^5.1.0",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"argon2": "^0.41.1", "argon2": "^0.41.1",
"astro": "^5.5.0", "astro": "^7.3.2",
"basic-ftp": "^5.0.5", "basic-ftp": "^5.0.5",
"grammy": "^1.30.0", "grammy": "^1.30.0",
"heic-convert": "^2.1.0", "heic-convert": "^2.1.0",
@@ -56,7 +55,7 @@
"pg-boss": "^10.1.5", "pg-boss": "^10.1.5",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"sharp": "^0.33.5", "sharp": "^0.35.4",
"ssh2-sftp-client": "^11.0.0" "ssh2-sftp-client": "^11.0.0"
}, },
"devDependencies": { "devDependencies": {
+4 -2
View File
@@ -38,9 +38,11 @@ const RULES = [
// Lines that are allowed to match: documentation of the shape of a secret, // Lines that are allowed to match: documentation of the shape of a secret,
// and the example file, which is meant to show the names without the values. // and the example file, which is meant to show the names without the values.
// Lines that are allowed to match: documentation of the *shape* of a secret.
// Being a comment is deliberately not a free pass — a pasted `.env` fragment is
// usually commented out, and that is exactly the mistake worth catching.
const ALLOW = [ const ALLOW = [
/example|placeholder|CHANGEME|your-|<[a-z]+>|openssl rand/i, /example|placeholder|CHANGEME|your-|<[a-z]+>|openssl rand|\.\.\.|xxxx/i,
/^\s*(?:\/\/|#|--|\*)/,
]; ];
let findings = 0; let findings = 0;
+1 -2
View File
@@ -274,7 +274,6 @@ export default function AdminApp({ locale, dict }: { locale?: Locale; dict?: Dic
<div className="field"><label>{t('Default dpi')}</label><input className="input" type="number" value={s.default_dpi ?? 300} onChange={(e) => field('default_dpi', e.target.value)} /></div> <div className="field"><label>{t('Default dpi')}</label><input className="input" type="number" value={s.default_dpi ?? 300} onChange={(e) => field('default_dpi', e.target.value)} /></div>
<div className="field"><label>{t('Concurrency')}</label><input className="input" type="number" value={s.concurrency ?? 2} onChange={(e) => field('concurrency', e.target.value)} /></div> <div className="field"><label>{t('Concurrency')}</label><input className="input" type="number" value={s.concurrency ?? 2} onChange={(e) => field('concurrency', e.target.value)} /></div>
</div> </div>
<div className="field"><label>{t('Cricut sheet size (cm)')}</label><input className="input" value={s.cricut_sheet_cm ?? ''} onChange={(e) => field('cricut_sheet_cm', e.target.value)} /></div>
<div className="field"><label>{t('Default file format')}</label> <div className="field"><label>{t('Default file format')}</label>
<select className="input" value={s.output_ext ?? 'png'} onChange={(e) => field('output_ext', e.target.value)}> <select className="input" value={s.output_ext ?? 'png'} onChange={(e) => field('output_ext', e.target.value)}>
<option value="png">{t('PNG (lossless, large)')}</option> <option value="png">{t('PNG (lossless, large)')}</option>
@@ -489,7 +488,7 @@ export default function AdminApp({ locale, dict }: { locale?: Locale; dict?: Dic
{targets.length === 0 && <div className="hint">{t('No additional targets yet.')}</div>} {targets.length === 0 && <div className="hint">{t('No additional targets yet.')}</div>}
</div> </div>
<div className="two"> <div className="two">
<div className="field"><label>{t('Name')}</label><input className="input" placeholder={t('e.g. The Frame FTP')} value={nt.name ?? ''} onChange={(e) => setNt({ ...nt, name: e.target.value })} /></div> <div className="field"><label>{t('Name')}</label><input className="input" placeholder={t('e.g. Studio archive')} value={nt.name ?? ''} onChange={(e) => setNt({ ...nt, name: e.target.value })} /></div>
<div className="field"><label>{t('Protocol')}</label> <div className="field"><label>{t('Protocol')}</label>
<select className="input" value={nt.protocol} onChange={(e) => setNt({ ...nt, protocol: e.target.value })}> <select className="input" value={nt.protocol} onChange={(e) => setNt({ ...nt, protocol: e.target.value })}>
<option value="sftp">SFTP</option><option value="ftps">FTPS</option></select></div> <option value="sftp">SFTP</option><option value="ftps">FTPS</option></select></div>
+12 -5
View File
@@ -110,7 +110,8 @@ function shrink(dataUrl: string, maxEdge = 700): Promise<{ url: string; w: numbe
/* ========================================================================== */ /* ========================================================================== */
export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dict | null }) { export default function PrintApp({ locale, dict, canDeliver = false }:
{ locale?: Locale; dict?: Dict | null; canDeliver?: boolean }) {
const t = useT(dict); const t = useT(dict);
const [cells, setCells] = useState<Cell[]>([]); const [cells, setCells] = useState<Cell[]>([]);
const [paperId, setPaperId] = useState('A4'); const [paperId, setPaperId] = useState('A4');
@@ -298,8 +299,13 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
useEffect(() => { useEffect(() => {
loadPresets(); loadPresets();
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {}); // Only ask when the delivery module is on. Asking anyway would work — the
}, []); // route answers 404 by design — but it puts a red line in the console of
// every print-only instance, which reads like a fault and is not one.
if (canDeliver) {
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
}
}, [canDeliver]);
const loadPresets = () => fetch('/api/print/presets').then((r) => r.json()) const loadPresets = () => fetch('/api/print/presets').then((r) => r.json())
.then((j) => setPresets(j.presets || [])).catch(() => {}); .then((j) => setPresets(j.presets || [])).catch(() => {});
@@ -413,7 +419,7 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name, fit: c.fit, bg: c.bg }), body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name, fit: c.fit, bg: c.bg }),
}); });
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || t('Failed.'), true); return; } if (!res.ok) { notify((await res.json().catch(() => ({}))).error || t('Failed.'), true); return; }
download(await res.blob(), filenameFromHeader(res.headers.get('Content-Disposition'), `bild.${ext}`)); download(await res.blob(), filenameFromHeader(res.headers.get('Content-Disposition'), `image.${ext}`));
const real = res.headers.get('X-Klarbild-Real-Dpi'); const real = res.headers.get('X-Klarbild-Real-Dpi');
notify(res.headers.get('X-Klarbild-Dpi-Ok') === '0' notify(res.headers.get('X-Klarbild-Dpi-Ok') === '0'
? t('Downloaded — careful: the source only carries about {dpi} dpi.', { dpi: String(real) }) ? t('Downloaded — careful: the source only carries about {dpi} dpi.', { dpi: String(real) })
@@ -706,6 +712,7 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
)} )}
</Group> </Group>
{canDeliver && (
<Group closed title={t('Where to?')} hint={deliverTo ? t('deliver as well') : t('download only')}> <Group closed title={t('Where to?')} hint={deliverTo ? t('deliver as well') : t('download only')}>
<select className="select" value={deliverTo} onChange={(e) => setDeliverTo(e.target.value)} <select className="select" value={deliverTo} onChange={(e) => setDeliverTo(e.target.value)}
aria-label={t('Additional target')}> aria-label={t('Additional target')}>
@@ -718,7 +725,7 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
<input className="input" style={{ marginTop: 7 }} value={gallery} onChange={(e) => setGallery(e.target.value)} <input className="input" style={{ marginTop: 7 }} value={gallery} onChange={(e) => setGallery(e.target.value)}
placeholder={t('Folder on the target (empty = default)')} aria-label={t('Folder on the target')} /> placeholder={t('Folder on the target (empty = default)')} aria-label={t('Folder on the target')} />
)} )}
</Group> </Group>)}
</div> </div>
</div> </div>
</Card> </Card>
+5 -5
View File
@@ -20,7 +20,7 @@ const FORMATS: { id: string; label: string; cm?: [number, number]; screen?: [num
{ id: 'A2', label: 'DIN A2', cm: [42, 59.4] }, { id: 'A2', label: 'DIN A2', cm: [42, 59.4] },
{ id: '20x20', label: '20 × 20 cm', cm: [20, 20] }, { id: '20x20', label: '20 × 20 cm', cm: [20, 20] },
{ id: '30x30', label: '30 × 30 cm', cm: [30, 30] }, { id: '30x30', label: '30 × 30 cm', cm: [30, 30] },
{ id: 'theframe', label: 'The Frame (16:9)', screen: [3840, 2160] }, { id: 'tv169', label: 'TV 16:9', screen: [3840, 2160] },
{ id: 'portrait916', label: 'Portrait (9:16)', screen: [2160, 3840] }, { id: 'portrait916', label: 'Portrait (9:16)', screen: [2160, 3840] },
{ id: 'sticker', label: 'Sticker (custom size)', cm: [5, 5] }, { id: 'sticker', label: 'Sticker (custom size)', cm: [5, 5] },
{ id: 'custom', label: 'Custom size (cm)' }, { id: 'custom', label: 'Custom size (cm)' },
@@ -85,7 +85,7 @@ export default function StudioApp({ recipes, locale, dict }: { recipes: any[]; l
const fmt = FORMATS.find((f) => f.id === format); const fmt = FORMATS.find((f) => f.id === format);
const hasCutout = tasks.includes('cutout'); const hasCutout = tasks.includes('cutout');
const hasFormat = tasks.includes('format'); const hasFormat = tasks.includes('format');
const theframeConflict = hasCutout && (format === 'theframe' || format === 'portrait916'); const tvFormatConflict = hasCutout && (format === 'tv169' || format === 'portrait916');
// In compose/generate the size is always available (without the "Format" task). // In compose/generate the size is always available (without the "Format" task).
const showFormat = mode === 'each' ? hasFormat : true; const showFormat = mode === 'each' ? hasFormat : true;
const wantsFormat = mode === 'each' ? hasFormat : format !== 'keep'; const wantsFormat = mode === 'each' ? hasFormat : format !== 'keep';
@@ -220,7 +220,7 @@ export default function StudioApp({ recipes, locale, dict }: { recipes: any[]; l
// "Custom size" needs a valid entry, otherwise the run cannot start. // "Custom size" needs a valid entry, otherwise the run cannot start.
const freeFmtInvalid = wantsFormat && format === 'custom' && !parsedFmt.cm; const freeFmtInvalid = wantsFormat && format === 'custom' && !parsedFmt.cm;
const canRun = !busy && !freeFmtInvalid && ( const canRun = !busy && !freeFmtInvalid && (
mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout)) mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !tvFormatConflict && !(tasks.includes('contour') && !hasCutout))
: mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0) : mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0)
: desc.trim().length > 0 : desc.trim().length > 0
); );
@@ -373,7 +373,7 @@ export default function StudioApp({ recipes, locale, dict }: { recipes: any[]; l
{freeFmtInvalid && <div className="hint warn">{t('Enter a size, for example “25x35” or “5”.')}</div>} {freeFmtInvalid && <div className="hint warn">{t('Enter a size, for example “25x35” or “5”.')}</div>}
</> </>
)} )}
{theframeConflict && <div className="hint warn">{t('Cut out + The Frame makes no sense — pick one.')}</div>} {tvFormatConflict && <div className="hint warn">{t('Cut out + TV 16:9 makes no sense — pick one.')}</div>}
{effCm && ( {effCm && (
<div className="switch"> <div className="switch">
<button className={portrait ? 'on' : ''} onClick={() => setPortrait(true)}>{t('Portrait')}</button> <button className={portrait ? 'on' : ''} onClick={() => setPortrait(true)}>{t('Portrait')}</button>
@@ -388,7 +388,7 @@ export default function StudioApp({ recipes, locale, dict }: { recipes: any[]; l
</div> </div>
)} )}
{targetPx && mode !== 'generate' && crop === 'extend' && ( {targetPx && mode !== 'generate' && crop === 'extend' && (
<div className="hint">{t('“Extend” produces more scene around the subject (outpainting) instead of cropping — made for The Frame.')}</div> <div className="hint">{t('“Extend” produces more scene around the subject (outpainting) instead of cropping — made for wide TV frames.')}</div>
)} )}
</div> </div>
)} )}
+2 -2
View File
@@ -16,7 +16,7 @@ const CM: Record<string, [number, number]> = {
// Pure screen sizes (fixed pixels, no cm/dpi) // Pure screen sizes (fixed pixels, no cm/dpi)
const SCREEN: Record<string, [number, number]> = { const SCREEN: Record<string, [number, number]> = {
theframe: [3840, 2160], // 16:9 landscape tv169: [3840, 2160], // 16:9 landscape
portrait916: [2160, 3840], // 9:16 portrait portrait916: [2160, 3840], // 9:16 portrait
}; };
@@ -39,7 +39,7 @@ export function resolveDimensions(input: ResolveInput): Dimensions | null {
if (format in SCREEN) { if (format in SCREEN) {
const [w, h] = SCREEN[format]; const [w, h] = SCREEN[format];
return { w, h, label: format === 'theframe' ? 'The Frame' : 'Portrait' }; return { w, h, label: format === 'tv169' ? 'TV 16:9' : 'Portrait' };
} }
let cm: [number, number] | undefined; let cm: [number, number] | undefined;
+3 -3
View File
@@ -3,7 +3,7 @@
// renaming, tests). // renaming, tests).
// //
// Scheme: `YYYY-MM-DD_HHMMSS_<motif>_<format>_<short-id>.<ext>` // Scheme: `YYYY-MM-DD_HHMMSS_<motif>_<format>_<short-id>.<ext>`
// 2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg // 2026-08-20_143207_portrait-badge_tv169_a3f9.jpg
// //
// Why: the timestamp comes first, so every file listing — delivery target, // Why: the timestamp comes first, so every file listing — delivery target,
// Finder, backup mirror — sorts chronologically by itself. The name becomes // Finder, backup mirror — sorts chronologically by itself. The name becomes
@@ -89,7 +89,7 @@ export interface NameParts {
/** /**
* Unique, chronologically sortable file name: * Unique, chronologically sortable file name:
* `YYYY-MM-DD_HHMMSS_<motif>_<format>_<short-id>.<ext>` * `YYYY-MM-DD_HHMMSS_<motif>_<format>_<short-id>.<ext>`
* e.g. `2026-08-20_143207_portrait_the-frame_a3f9.png` * e.g. `2026-08-20_143207_portrait_tv169_a3f9.png`
* *
* Why like this: the name begins with the timestamp, so every file listing * Why like this: the name begins with the timestamp, so every file listing
* (delivery target, Finder, backup mirror) sorts chronologically by itself. It * (delivery target, Finder, backup mirror) sorts chronologically by itself. It
@@ -142,7 +142,7 @@ export function newNameFor(it: { id: string; filename: string | null; created_at
/** Format key for the file name (from the output_format). */ /** Format key for the file name (from the output_format). */
export function formatToken(outputFormat: string | undefined): string { export function formatToken(outputFormat: string | undefined): string {
if (!outputFormat || outputFormat === 'keep') return 'original'; if (!outputFormat || outputFormat === 'keep') return 'original';
if (outputFormat === 'theframe') return 'the-frame'; if (outputFormat === 'tv169') return 'tv169';
if (outputFormat === 'portrait916') return 'portrait916'; if (outputFormat === 'portrait916') return 'portrait916';
return outputFormat.toLowerCase(); // 30x40, a4, sticker, … return outputFormat.toLowerCase(); // 30x40, a4, sticker, …
} }
+1 -1
View File
@@ -128,7 +128,7 @@ export const PHOTO_SIZES: PhotoSize[] = [
{ id: 'C70x100', label: 'Bookmark 7 × 21 cm', w: 70, h: 210, group: 'Cards' }, { id: 'C70x100', label: 'Bookmark 7 × 21 cm', w: 70, h: 210, group: 'Cards' },
// --- Aspect ratios as a print size (worked out on 30 cm) --- // --- Aspect ratios as a print size (worked out on 30 cm) ---
{ id: 'W16x9', label: '16:9 "The Frame" (30 cm wide)', w: 300, h: 168.75, group: 'Aspect ratio' }, { id: 'W16x9', label: '16:9 TV 16:9 (30 cm wide)', w: 300, h: 168.75, group: 'Aspect ratio' },
{ id: 'W9x16', label: '9:16 portrait (30 cm tall)', w: 168.75, h: 300, group: 'Aspect ratio' }, { id: 'W9x16', label: '9:16 portrait (30 cm tall)', w: 168.75, h: 300, group: 'Aspect ratio' },
{ id: 'W21x9', label: '21:9 cinema (30 cm wide)', w: 300, h: 128.57, group: 'Aspect ratio' }, { id: 'W21x9', label: '21:9 cinema (30 cm wide)', w: 300, h: 128.57, group: 'Aspect ratio' },
{ id: 'W2x1', label: '2:1 panorama (30 cm wide)', w: 300, h: 150, group: 'Aspect ratio' }, { id: 'W2x1', label: '2:1 panorama (30 cm wide)', w: 300, h: 150, group: 'Aspect ratio' },
+2 -2
View File
@@ -1,7 +1,7 @@
// Rendering the print sheet: crop (sharp) + PDF assembly (pdf-lib). // Rendering the print sheet: crop (sharp) + PDF assembly (pdf-lib).
// Deliberately without AI — plain geometry and scaling, so that the result is // Deliberately without AI — plain geometry and scaling, so that the result is
// predictable and repeatable. // predictable and repeatable.
import sharp from 'sharp'; import sharp, { type Sharp } from 'sharp';
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib'; import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
import { mmToPt, mmToPx } from './paper.ts'; import { mmToPt, mmToPx } from './paper.ts';
import type { Line, Page, SheetSpec } from './printlayout.ts'; import type { Line, Page, SheetSpec } from './printlayout.ts';
@@ -112,7 +112,7 @@ export async function renderCell(
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg'; const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg }); if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg });
const encode = (pipe: sharp.Sharp) => (ext === 'png' const encode = (pipe: Sharp) => (ext === 'png'
? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 }) ? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
: pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true })); : pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }));
+11 -8
View File
@@ -44,14 +44,17 @@ async function seedAdmin(): Promise<void> {
} else { } else {
// Printed once, on the first boot only. There is no way to recover it later; // Printed once, on the first boot only. There is no way to recover it later;
// the reset flow (module "mail") or a fresh volume is the way back. // the reset flow (module "mail") or a fresh volume is the way back.
const lines = [
'Klarbild is set up. Sign in with:',
` user ${username}`,
` password ${password}`,
'Shown once. Change it under Account after you sign in.',
];
const w = Math.max(...lines.map((l) => l.length)) + 2;
console.log( console.log(
'\n' + '\n ┌' + '─'.repeat(w) + '┐\n' +
' ┌────────────────────────────────────────────────────────────┐\n' + lines.map((l) => ` │ ${l.padEnd(w - 1)}│`).join('\n') +
' │ Klarbild is set up. Sign in with: │\n' + '\n └' + '─'.repeat(w) + '┘\n',
` │ user ${username.padEnd(46)}│\n` +
` │ password ${password.padEnd(46)}│\n` +
' │ This is shown once. Change it under Account after login. │\n' +
' └────────────────────────────────────────────────────────────┘\n',
); );
} }
} }
@@ -85,7 +88,7 @@ async function seedRecipes(): Promise<void> {
await R('Clean up only', ['clean'], 'keep', 'landscape', { is_default: true }); await R('Clean up only', ['clean'], 'keep', 'landscape', { is_default: true });
await R('Poster 30×40', ['clean', 'format'], '30x40', 'portrait'); await R('Poster 30×40', ['clean', 'format'], '30x40', 'portrait');
await R('TV frame (16:9)', ['clean', 'format'], 'theframe', 'landscape'); await R('TV frame (16:9)', ['clean', 'format'], 'tv169', 'landscape');
await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape', { contour_mm: 3 }); await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape', { contour_mm: 3 });
} }
+44 -16
View File
@@ -1,5 +1,6 @@
{ {
"(enforced)": "(erzwungen)", "(enforced)": "(erzwungen)",
"+ Alt.": "+ Alt.",
".md off": ".md aus", ".md off": ".md aus",
".md on": ".md an", ".md on": ".md an",
"0 = unlimited.": "0 = unbegrenzt.", "0 = unlimited.": "0 = unbegrenzt.",
@@ -10,7 +11,7 @@
"1 · Edit an image": "1 · Bild bearbeiten", "1 · Edit an image": "1 · Bild bearbeiten",
"13 × 18 cm → measured 130.1 × 180.1 mm": "13 × 18 cm → gemessen 130,1 × 180,1 mm", "13 × 18 cm → measured 130.1 × 180.1 mm": "13 × 18 cm → gemessen 130,1 × 180,1 mm",
"150 — draft": "150 — Entwurf", "150 — draft": "150 — Entwurf",
"16:9 \"The Frame\" (30 cm wide)": "16:9 „The Frame\" (30 cm breit)", "16:9 TV (30 cm wide)": "16:9 TV 16:9 (30 cm breit)",
"18 Aug 2026": "18.08.2026", "18 Aug 2026": "18.08.2026",
"19 Aug 2026": "19.08.2026", "19 Aug 2026": "19.08.2026",
"1:1 square (30 cm)": "1:1 quadratisch (30 cm)", "1:1 square (30 cm)": "1:1 quadratisch (30 cm)",
@@ -37,6 +38,7 @@
"3:4 (30 cm tall)": "3:4 (30 cm hoch)", "3:4 (30 cm tall)": "3:4 (30 cm hoch)",
"4 · Library, folders, alternatives & keep editing": "4 · Bibliothek, Ordner, Alternativen & Weiterbearbeiten", "4 · Library, folders, alternatives & keep editing": "4 · Bibliothek, Ordner, Alternativen & Weiterbearbeiten",
"4.5 × 6 cm": "4,5 × 6 cm", "4.5 × 6 cm": "4,5 × 6 cm",
"465 = SSL · 587 = STARTTLS": "465 = SSL · 587 = STARTTLS",
"4:3 (30 cm wide)": "4:3 (30 cm breit)", "4:3 (30 cm wide)": "4:3 (30 cm breit)",
"4:5 Instagram (30 cm tall)": "4:5 Instagram (30 cm hoch)", "4:5 Instagram (30 cm tall)": "4:5 Instagram (30 cm hoch)",
"5 · Print — exact sizes and crop marks (without AI)": "5 · Drucken — exakte Maße und Schnittmarken (ohne KI)", "5 · Print — exact sizes and crop marks (without AI)": "5 · Drucken — exakte Maße und Schnittmarken (ohne KI)",
@@ -92,10 +94,10 @@
"<b>Exact sizes instead of “roughly”.</b> A passport photo is 35 × 45 mm, a frame is 13 × 18 cm. The tool computes in millimetres and produces a PDF whose page is exactly the sheet size and in which every image is exactly its target size. When printing, choose “Actual size / 100%” — not “fit to page”.": "<b>Exakte Maße statt „ungefähr\".</b> Ein Passbild ist 35 × 45 mm, ein Rahmen 13 × 18 cm. Das Werkzeug rechnet in Millimetern und erzeugt ein PDF, dessen Seite exakt das Bogenmaß hat und in dem jedes Bild exakt sein Zielmaß hat. Beim Drucken „Tatsächliche Größe / 100 %\" wählen — nicht „an Seitengröße anpassen\".", "<b>Exact sizes instead of “roughly”.</b> A passport photo is 35 × 45 mm, a frame is 13 × 18 cm. The tool computes in millimetres and produces a PDF whose page is exactly the sheet size and in which every image is exactly its target size. When printing, choose “Actual size / 100%” — not “fit to page”.": "<b>Exakte Maße statt „ungefähr\".</b> Ein Passbild ist 35 × 45 mm, ein Rahmen 13 × 18 cm. Das Werkzeug rechnet in Millimetern und erzeugt ein PDF, dessen Seite exakt das Bogenmaß hat und in dem jedes Bild exakt sein Zielmaß hat. Beim Drucken „Tatsächliche Größe / 100 %\" wählen — nicht „an Seitengröße anpassen\".",
"<b>Failed jobs are now red</b> in the overview (they used to be green) and carry the reason. Fixed: the “Sticker 5 cm” preset recipe led nowhere (“Unknown format: sticker5”).": "<b>Fehlgeschlagene Aufträge sind jetzt rot</b> in der Übersicht (vorher grün) und tragen den Grund. Behoben: das Preset „Sticker 5 cm\" lief ins Leere („Unbekanntes Format: sticker5\").", "<b>Failed jobs are now red</b> in the overview (they used to be green) and carry the reason. Fixed: the “Sticker 5 cm” preset recipe led nowhere (“Unknown format: sticker5”).": "<b>Fehlgeschlagene Aufträge sind jetzt rot</b> in der Übersicht (vorher grün) und tragen den Grund. Behoben: das Preset „Sticker 5 cm\" lief ins Leere („Unbekanntes Format: sticker5\").",
"<b>Faster loading:</b> the full view and the preview come as a small JPG (about 5× smaller), tiles load a thumbnail first; missing thumbnails can be generated afterwards with a button.": "<b>Schnelleres Laden:</b> Vollbild/Vorschau kommt als kleines JPG (~5× kleiner), Kacheln laden zuerst ein Thumbnail; fehlende Thumbnails per Knopf nachgenerierbar.", "<b>Faster loading:</b> the full view and the preview come as a small JPG (about 5× smaller), tiles load a thumbnail first; missing thumbnails can be generated afterwards with a button.": "<b>Schnelleres Laden:</b> Vollbild/Vorschau kommt als kleines JPG (~5× kleiner), Kacheln laden zuerst ein Thumbnail; fehlende Thumbnails per Knopf nachgenerierbar.",
"<b>File names are unique.</b> Every result is called <code>YYYY-MM-DD_HHMMSS_subject_format_shortid</code>, for example <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code>. Because date and time come first, every file listing — the delivery target, the backup mirror, the file manager — sorts chronologically by itself. The short id at the end comes from the image's id: <b>two images can no longer overwrite each other on delivery</b>, even if you generate the same subject in the same format twice on one day. (Before 21 Aug 2026 that was different — the first image was lost in such a case. Under <b>Admin → File names</b> the existing library can be renamed after the fact.)": "<b>Dateinamen sind eindeutig.</b> Jedes Ergebnis heißt <code>JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</code>, also z. B. <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code>. Weil Datum und Uhrzeit vorn stehen, sortiert jede Dateiliste — Auslieferungsziel, Spiegel, Dateimanager — von allein chronologisch. Das Kürzel am Ende stammt aus der Kennung des Bildes: <b>zwei Bilder können sich beim Ausliefern nicht mehr überschreiben</b>, auch wenn du am selben Tag zweimal dasselbe Motiv im selben Format erzeugst. (Vor dem 21.08.2026 war das anders — dort ging in so einem Fall das erste Bild verloren. Unter <b>Admin → Dateinamen</b> lässt sich die vorhandene Bibliothek nachträglich umbenennen.)", "<b>File names are unique.</b> Every result is called <code>YYYY-MM-DD_HHMMSS_subject_format_shortid</code>, for example <code>2026-08-20_143207_ada-badge_tv169_a3f9.jpg</code>. Because date and time come first, every file listing — the delivery target, the backup mirror, the file manager — sorts chronologically by itself. The short id at the end comes from the image's id: <b>two images can no longer overwrite each other on delivery</b>, even if you generate the same subject in the same format twice on one day. (Before 21 Aug 2026 that was different — the first image was lost in such a case. Under <b>Admin → File names</b> the existing library can be renamed after the fact.)": "<b>Dateinamen sind eindeutig.</b> Jedes Ergebnis heißt <code>JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</code>, also z. B. <code>2026-08-20_143207_ada-badge_tv169_a3f9.jpg</code>. Weil Datum und Uhrzeit vorn stehen, sortiert jede Dateiliste — Auslieferungsziel, Spiegel, Dateimanager — von allein chronologisch. Das Kürzel am Ende stammt aus der Kennung des Bildes: <b>zwei Bilder können sich beim Ausliefern nicht mehr überschreiben</b>, auch wenn du am selben Tag zweimal dasselbe Motiv im selben Format erzeugst. (Vor dem 21.08.2026 war das anders — dort ging in so einem Fall das erste Bild verloren. Unter <b>Admin → Dateinamen</b> lässt sich die vorhandene Bibliothek nachträglich umbenennen.)",
"<b>Finish time in the job:</b> the queue and the job show when it was done.": "<b>Endzeit im Auftrag:</b> Warteschlange und Auftrag zeigen, wann er fertig wurde.", "<b>Finish time in the job:</b> the queue and the job show when it was done.": "<b>Endzeit im Auftrag:</b> Warteschlange und Auftrag zeigen, wann er fertig wurde.",
"<b>Fixed: images overwrote each other on the delivery target.</b> Generating the same subject in the same format twice on one day — two badges, say — produced the same file name twice (<code>2026-07-23_portrait_the-frame.png</code>). On delivery the second image then silently replaced the first. That can no longer happen.": "<b>Behoben: Bilder haben sich am Auslieferungsziel gegenseitig überschrieben.</b> Wer am selben Tag zweimal dasselbe Motiv im selben Format erzeugt hat — etwa zwei Badges —, bekam zweimal denselben Dateinamen (<code>2026-07-23_portrait_the-frame.png</code>). Beim Ausliefern ersetzte das zweite Bild dann stillschweigend das erste. Das kann jetzt nicht mehr passieren.", "<b>Fixed: images overwrote each other on the delivery target.</b> Generating the same subject in the same format twice on one day — two badges, say — produced the same file name twice (<code>2026-07-23_portrait_tv169.png</code>). On delivery the second image then silently replaced the first. That can no longer happen.": "<b>Behoben: Bilder haben sich am Auslieferungsziel gegenseitig überschrieben.</b> Wer am selben Tag zweimal dasselbe Motiv im selben Format erzeugt hat — etwa zwei Badges —, bekam zweimal denselben Dateinamen (<code>2026-07-23_portrait_tv169.png</code>). Beim Ausliefern ersetzte das zweite Bild dann stillschweigend das erste. Das kann jetzt nicht mehr passieren.",
"<b>Fixed: print files were all called the same.</b> Two sheets on the same day were both <code>2026-09-01_druckbogen.pdf</code> — in the same folder the second overwrote the first. Now it is <code>2026-09-01_143207_druck-9-teile_a4_4eca.pdf</code>: date, time to the second, what is on the sheet, the paper and a short id. The short id belongs to the history entry — so you can see which print was which file. Single images are now named after their subject.": "<b>Behoben: Druckdateien hießen alle gleich.</b> Zwei Bogen am selben Tag waren beide <code>2026-09-01_druckbogen.pdf</code> — im selben Ordner überschrieb der zweite den ersten. Jetzt <code>2026-09-01_143207_druck-9-teile_a4_4eca.pdf</code>: Datum, Uhrzeit auf die Sekunde, was auf dem Bogen liegt, das Papier und ein Kürzel. Das Kürzel gehört zum Verlaufseintrag — du siehst also, welcher Druck welche Datei war. Einzelbilder heißen jetzt nach ihrem Motiv.", "<b>Fixed: print files were all called the same.</b> Two sheets on the same day were both <code>2026-09-01_print-sheet.pdf</code> — in the same folder the second overwrote the first. Now it is <code>2026-09-01_143207_print-9-pieces_a4_4eca.pdf</code>: date, time to the second, what is on the sheet, the paper and a short id. The short id belongs to the history entry — so you can see which print was which file. Single images are now named after their subject.": "<b>Behoben: Druckdateien hießen alle gleich.</b> Zwei Bogen am selben Tag waren beide <code>2026-09-01_druckbogen.pdf</code> — im selben Ordner überschrieb der zweite den ersten. Jetzt <code>2026-09-01_143207_druck-9-teile_a4_4eca.pdf</code>: Datum, Uhrzeit auf die Sekunde, was auf dem Bogen liegt, das Papier und ein Kürzel. Das Kürzel gehört zum Verlaufseintrag — du siehst also, welcher Druck welche Datei war. Einzelbilder heißen jetzt nach ihrem Motiv.",
"<b>Fixed: the crop showed the image distorted.</b> The frame in the crop window did not have the target aspect ratio, which squeezed the image into it (measured: 8.3% too wide). Frame and image now both compute from the same ratio.": "<b>Behoben: Der Zuschnitt zeigte das Bild verzerrt.</b> Der Rahmen im Zuschnitt-Fenster hatte nicht das Ziel-Seitenverhältnis, dadurch wurde das Bild hineingequetscht (gemessen: 8,3 % zu breit). Rahmen und Bild rechnen jetzt beide mit demselben Verhältnis.", "<b>Fixed: the crop showed the image distorted.</b> The frame in the crop window did not have the target aspect ratio, which squeezed the image into it (measured: 8.3% too wide). Frame and image now both compute from the same ratio.": "<b>Behoben: Der Zuschnitt zeigte das Bild verzerrt.</b> Der Rahmen im Zuschnitt-Fenster hatte nicht das Ziel-Seitenverhältnis, dadurch wurde das Bild hineingequetscht (gemessen: 8,3 % zu breit). Rahmen und Bild rechnen jetzt beide mit demselben Verhältnis.",
"<b>Fixed: “I cannot move the image.”</b> Technically you could, but with the frame fully filled the crop sits against the stop in one direction — dragging did nothing there. It now says which way the image can still move; double-tap zooms, the arrow keys nudge, and the zoom slider shows its factor.": "<b>Behoben: „Ich kann das Bild nicht verschieben.\"</b> Technisch ging es, aber bei voll ausgefülltem Rahmen sitzt der Ausschnitt in einer Richtung am Anschlag — Ziehen bewirkte dort nichts. Jetzt steht dabei, wohin sich das Bild noch bewegen lässt; Doppeltipp zoomt, Pfeiltasten schieben, und der Zoomregler zeigt seinen Faktor.", "<b>Fixed: “I cannot move the image.”</b> Technically you could, but with the frame fully filled the crop sits against the stop in one direction — dragging did nothing there. It now says which way the image can still move; double-tap zooms, the arrow keys nudge, and the zoom slider shows its factor.": "<b>Behoben: „Ich kann das Bild nicht verschieben.\"</b> Technisch ging es, aber bei voll ausgefülltem Rahmen sitzt der Ausschnitt in einer Richtung am Anschlag — Ziehen bewirkte dort nichts. Jetzt steht dabei, wohin sich das Bild noch bewegen lässt; Doppeltipp zoomt, Pfeiltasten schieben, und der Zoomregler zeigt seinen Faktor.",
"<b>Fixed:</b> a new password now also ends every sign-in that is still open. An existing session used to stay valid for up to 30 days — so resetting your password because somebody else had got in did nothing for you.": "<b>Behoben:</b> Ein neues Passwort beendet jetzt auch alle noch offenen Anmeldungen. Vorher blieb eine bestehende Sitzung bis zu 30 Tage gültig — wer sein Passwort zurücksetzte, weil jemand anderes hineingekommen war, hatte davon also nichts.", "<b>Fixed:</b> a new password now also ends every sign-in that is still open. An existing session used to stay valid for up to 30 days — so resetting your password because somebody else had got in did nothing for you.": "<b>Behoben:</b> Ein neues Passwort beendet jetzt auch alle noch offenen Anmeldungen. Vorher blieb eine bestehende Sitzung bis zu 30 Tage gültig — wer sein Passwort zurücksetzte, weil jemand anderes hineingekommen war, hatte davon also nichts.",
@@ -148,14 +150,14 @@
"<b>Share</b>: in the library, tap <b>Share</b> — for one image or for several selected ones at once. The result is a link that someone <b>without a Klarbild account</b> can open too; from there the image can be downloaded at full resolution.": "<b>Teilen</b>: In der Bibliothek auf <b>Teilen</b> tippen — einzeln oder für mehrere markierte Bilder auf einmal. Es entsteht ein Link, den auch jemand <b>ohne Klarbild-Konto</b> öffnen kann; von dort lässt sich das Bild in voller Auflösung herunterladen.", "<b>Share</b>: in the library, tap <b>Share</b> — for one image or for several selected ones at once. The result is a link that someone <b>without a Klarbild account</b> can open too; from there the image can be downloaded at full resolution.": "<b>Teilen</b>: In der Bibliothek auf <b>Teilen</b> tippen — einzeln oder für mehrere markierte Bilder auf einmal. Es entsteht ein Link, den auch jemand <b>ohne Klarbild-Konto</b> öffnen kann; von dort lässt sich das Bild in voller Auflösung herunterladen.",
"<b>Staying safe</b>: every link expires by itself (30 days by default), can be <b>switched off</b> at any time and protected with a <b>passphrase</b> if you want. Send the passphrase separately from the link — otherwise it is of no use.": "<b>Sicher bleiben</b>: Jeder Link läuft von selbst ab (Standard 30 Tage), lässt sich jederzeit <b>abschalten</b> und auf Wunsch mit einem <b>Zugangswort</b> schützen. Das Wort schickst du dann getrennt vom Link — sonst nützt es nichts.", "<b>Staying safe</b>: every link expires by itself (30 days by default), can be <b>switched off</b> at any time and protected with a <b>passphrase</b> if you want. Send the passphrase separately from the link — otherwise it is of no use.": "<b>Sicher bleiben</b>: Jeder Link läuft von selbst ab (Standard 30 Tage), lässt sich jederzeit <b>abschalten</b> und auf Wunsch mit einem <b>Zugangswort</b> schützen. Das Wort schickst du dann getrennt vom Link — sonst nützt es nichts.",
"<b>Storage management:</b> thumbnails, deleting sources, an automatic retention period.": "<b>Speicherverwaltung:</b> Vorschaubilder, Quellen löschen, automatische Aufbewahrungsfrist.", "<b>Storage management:</b> thumbnails, deleting sources, an automatic retention period.": "<b>Speicherverwaltung:</b> Vorschaubilder, Quellen löschen, automatische Aufbewahrungsfrist.",
"<b>TV 16:9 “Extend”:</b> generate more scene around the subject (outpainting) instead of only cropping.": "<b>TV 16:9 „Erweitern\":</b> Szene rund ums Motiv weiter generieren (Outpainting) statt nur zuschneiden.",
"<b>TV 16:9</b> now delivers automatically into the folder <b>“TV-Backgrounds”</b> on the target.": "<b>TV 16:9</b> liefert jetzt automatisch in die Galerie <b>„TV-Backgrounds\"</b>.",
"<b>Telegram & MCP keep up:</b> the Telegram bot and the MCP server (for assistants) now know the selectable file format and the custom sizes too — whichever way you start a job.": "<b>Telegram & MCP ziehen mit:</b> Der Telegram-Bot und der MCP-Server (für Assistenten) kennen jetzt ebenfalls das wählbare Dateiformat und die eigenen Formate — egal, worüber du einen Auftrag startest.", "<b>Telegram & MCP keep up:</b> the Telegram bot and the MCP server (for assistants) now know the selectable file format and the custom sizes too — whichever way you start a job.": "<b>Telegram & MCP ziehen mit:</b> Der Telegram-Bot und der MCP-Server (für Assistenten) kennen jetzt ebenfalls das wählbare Dateiformat und die eigenen Formate — egal, worüber du einen Auftrag startest.",
"<b>Telegram entirely button-driven:</b> a permanent keyboard & menu — Edit, Combine, “✨ New image”, Keep editing, with no commands at all.": "<b>Telegram komplett knopfbasiert:</b> Dauertastatur & Menü — Bearbeiten, Kombinieren, „✨ Neues Bild\", Weiterbearbeiten, ganz ohne Befehle.", "<b>Telegram entirely button-driven:</b> a permanent keyboard & menu — Edit, Combine, “✨ New image”, Keep editing, with no commands at all.": "<b>Telegram komplett knopfbasiert:</b> Dauertastatur & Menü — Bearbeiten, Kombinieren, „✨ Neues Bild\", Weiterbearbeiten, ganz ohne Befehle.",
"<b>The Frame “Extend”:</b> generate more scene around the subject (outpainting) instead of only cropping.": "<b>The Frame „Erweitern\":</b> Szene rund ums Motiv weiter generieren (Outpainting) statt nur zuschneiden.",
"<b>The Frame</b> now delivers automatically into the folder <b>“TheFrame-Backgrounds”</b> on the target.": "<b>The Frame</b> liefert jetzt automatisch in die Galerie <b>„TheFrame-Backgrounds\"</b>.",
"<b>The area is now called “Print”</b> (it was “Passport photos”) — it does far more than passport photos, after all. Old links redirect automatically.": "<b>Der Bereich heißt jetzt „Drucken\"</b> (vorher „Passbilder\") — er kann ja weit mehr als Passbilder. Alte Verweise leiten automatisch weiter.", "<b>The area is now called “Print”</b> (it was “Passport photos”) — it does far more than passport photos, after all. Old links redirect automatically.": "<b>Der Bereich heißt jetzt „Drucken\"</b> (vorher „Passbilder\") — er kann ja weit mehr als Passbilder. Alte Verweise leiten automatisch weiter.",
"<b>The delivery target gets exactly your format:</b> what is delivered is now exactly the file format you set — globally in the admin settings <i>or</i> per job in the studio (PNG or JPG). No more forced conversion. Cut-out subjects always stay PNG. (The images that had “disappeared” in the meantime were there the whole time, by the way — it was the sort order in the target’s web interface.)": "<b>Das Auslieferungsziel bekommt genau dein Format:</b> Ausgeliefert wird jetzt exakt das Dateiformat, das du eingestellt hast — global in den Admin-Einstellungen <i>oder</i> pro Auftrag im Studio (PNG oder JPG). Keine erzwungene Umwandlung mehr. Freigestellte Motive bleiben immer PNG. (Die zwischenzeitlich „verschwundenen\" Bilder waren übrigens die ganze Zeit da — es lag an der Sortierung in der Weboberfläche des Ziels.)", "<b>The delivery target gets exactly your format:</b> what is delivered is now exactly the file format you set — globally in the admin settings <i>or</i> per job in the studio (PNG or JPG). No more forced conversion. Cut-out subjects always stay PNG. (The images that had “disappeared” in the meantime were there the whole time, by the way — it was the sort order in the target’s web interface.)": "<b>Das Auslieferungsziel bekommt genau dein Format:</b> Ausgeliefert wird jetzt exakt das Dateiformat, das du eingestellt hast — global in den Admin-Einstellungen <i>oder</i> pro Auftrag im Studio (PNG oder JPG). Keine erzwungene Umwandlung mehr. Freigestellte Motive bleiben immer PNG. (Die zwischenzeitlich „verschwundenen\" Bilder waren übrigens die ganze Zeit da — es lag an der Sortierung in der Weboberfläche des Ziels.)",
"<b>The file format can be chosen:</b> globally in the admin settings (default file format PNG/JPG) <i>and</i> per conversion in the studio (default / PNG / JPG). JPG only without transparency.": "<b>Dateiformat wählbar:</b> global in den Admin-Einstellungen (Standard-Dateiformat PNG/JPG) <i>und</i> pro Umwandlung im Studio (Standard / PNG / JPG). JPG nur ohne Transparenz.", "<b>The file format can be chosen:</b> globally in the admin settings (default file format PNG/JPG) <i>and</i> per conversion in the studio (default / PNG / JPG). JPG only without transparency.": "<b>Dateiformat wählbar:</b> global in den Admin-Einstellungen (Standard-Dateiformat PNG/JPG) <i>und</i> pro Umwandlung im Studio (Standard / PNG / JPG). JPG nur ohne Transparenz.",
"<b>The new name:</b> <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code> — date, time to the second, subject, format and a short id. Because the timestamp comes first, <b>every file listing sorts chronologically by itself</b> — on the delivery target, in the file manager, on the backup mirror. The short id comes from the image's id and is therefore guaranteed to be unique.": "<b>Der neue Name:</b> <code>2026-08-20_143207_ada-badge_the-frame_a3f9.jpg</code> — also Datum, Uhrzeit auf die Sekunde, Motiv, Format und ein kurzes Kürzel. Weil der Zeitstempel vorn steht, sortiert <b>jede Dateiliste von allein chronologisch</b> — am Auslieferungsziel, im Dateimanager, auf dem Spiegel. Das Kürzel stammt aus der Bild-Kennung und ist damit garantiert eindeutig.", "<b>The new name:</b> <code>2026-08-20_143207_ada-badge_tv169_a3f9.jpg</code> — date, time to the second, subject, format and a short id. Because the timestamp comes first, <b>every file listing sorts chronologically by itself</b> — on the delivery target, in the file manager, on the backup mirror. The short id comes from the image's id and is therefore guaranteed to be unique.": "<b>Der neue Name:</b> <code>2026-08-20_143207_ada-badge_tv169_a3f9.jpg</code> — also Datum, Uhrzeit auf die Sekunde, Motiv, Format und ein kurzes Kürzel. Weil der Zeitstempel vorn steht, sortiert <b>jede Dateiliste von allein chronologisch</b> — am Auslieferungsziel, im Dateimanager, auf dem Spiegel. Das Kürzel stammt aus der Bild-Kennung und ist damit garantiert eindeutig.",
"<b>The same image at two sizes?</b> Tap <b>Duplicate</b> — the image appears a second time in the list and gets its own size there. No more renaming and uploading again.": "<b>Dasselbe Bild in zwei Maßen?</b> <b>Duplizieren</b> antippen — das Bild erscheint ein zweites Mal in der Liste und bekommt dort sein eigenes Maß. Kein Umbenennen und erneutes Hochladen mehr.", "<b>The same image at two sizes?</b> Tap <b>Duplicate</b> — the image appears a second time in the list and gets its own size there. No more renaming and uploading again.": "<b>Dasselbe Bild in zwei Maßen?</b> <b>Duplizieren</b> antippen — das Bild erscheint ein zweites Mal in der Liste und bekommt dort sein eigenes Maß. Kein Umbenennen und erneutes Hochladen mehr.",
"<b>The time is local time</b>, not UTC. An image made at 00:30 now carries the right date instead of the previous day's.": "<b>Uhrzeit in deutscher Zeit</b>, nicht in UTC. Ein Bild von 0:30 Uhr trägt jetzt das richtige Datum und nicht mehr das des Vortags.", "<b>The time is local time</b>, not UTC. An image made at 00:30 now carries the right date instead of the previous day's.": "<b>Uhrzeit in deutscher Zeit</b>, nicht in UTC. Ein Bild von 0:30 Uhr trägt jetzt das richtige Datum und nicht mehr das des Vortags.",
"<b>Thumbnails</b> for the library (fast & frugal).": "<b>Vorschaubilder</b> für die Bibliothek (schnell & sparsam).", "<b>Thumbnails</b> for the library (fast & frugal).": "<b>Vorschaubilder</b> für die Bibliothek (schnell & sparsam).",
@@ -175,6 +177,7 @@
"API token for external/programmatic access (for example Klarbild MCP: “take these images and process them”).": "API-Token für externen/programmatischen Zugriff (z. B. Klarbild-MCP: „nimm diese Bilder und verarbeite sie\").", "API token for external/programmatic access (for example Klarbild MCP: “take these images and process them”).": "API-Token für externen/programmatischen Zugriff (z. B. Klarbild-MCP: „nimm diese Bilder und verarbeite sie\").",
"API token generated.": "API-Token erzeugt.", "API token generated.": "API-Token erzeugt.",
"Access & limits": "Zugang & Grenzen", "Access & limits": "Zugang & Grenzen",
"Account": "Konto",
"Add a note": "Dazu schreiben", "Add a note": "Dazu schreiben",
"Add a white sticker border (only together with Cut out).": "Weißen Stickerrand anlegen (nur mit Freistellen).", "Add a white sticker border (only together with Cut out).": "Weißen Stickerrand anlegen (nur mit Freistellen).",
"Add target": "Ziel hinzufügen", "Add target": "Ziel hinzufügen",
@@ -182,6 +185,8 @@
"Additional target": "Zusätzliches Ziel", "Additional target": "Zusätzliches Ziel",
"Address removed.": "Adresse entfernt.", "Address removed.": "Adresse entfernt.",
"Address saved.": "Adresse gespeichert.", "Address saved.": "Adresse gespeichert.",
"Admin": "Admin",
"Admin · Klarbild": "Admin · Klarbild",
"Admin → Email": "Admin → E-Mail", "Admin → Email": "Admin → E-Mail",
"Admins always see everything. “Own only”: everyone sees only the images they generated themselves.": "Admins sehen immer alles. „Nur eigene\": jede/r sieht ausschließlich die selbst erzeugten Bilder.", "Admins always see everything. “Own only”: everyone sees only the images they generated themselves.": "Admins sehen immer alles. „Nur eigene\": jede/r sieht ausschließlich die selbst erzeugten Bilder.",
"Again": "Erneut", "Again": "Erneut",
@@ -219,6 +224,7 @@
"Back up all images": "Alle Bilder sichern", "Back up all images": "Alle Bilder sichern",
"Backing up to the mirror … (may take a while)": "Sichere auf den Sicherungsspiegel … (kann dauern)", "Backing up to the mirror … (may take a while)": "Sichere auf den Sicherungsspiegel … (kann dauern)",
"Backing up … (may take a while)": "Sichere … (kann dauern)", "Backing up … (may take a while)": "Sichere … (kann dauern)",
"Backup": "Sicherung",
"Backup mirror": "Zweite Sicherung", "Backup mirror": "Zweite Sicherung",
"Backup mirror (second copy)": "Sicherungsspiegel (zweite Sicherung)", "Backup mirror (second copy)": "Sicherungsspiegel (zweite Sicherung)",
"Backup off": "Backup aus", "Backup off": "Backup aus",
@@ -251,6 +257,9 @@
"Change passphrase": "Wort ändern", "Change passphrase": "Wort ändern",
"Change validity": "Gültigkeit ändern", "Change validity": "Gültigkeit ändern",
"Change validity …": "Gültigkeit ändern …", "Change validity …": "Gültigkeit ändern …",
"Changelog": "Änderungen",
"Changelog · Klarbild": "Änderungen · Klarbild",
"Chat {id}": "Chat {id}",
"Check library": "Bibliothek prüfen", "Check library": "Bibliothek prüfen",
"Checking connection …": "Prüfe Verbindung …", "Checking connection …": "Prüfe Verbindung …",
"Checking file names …": "Prüfe Dateinamen …", "Checking file names …": "Prüfe Dateinamen …",
@@ -262,10 +271,10 @@
"Choose a way, otherwise the image is left out.": "Wähle einen Weg, sonst bleibt das Bild außen vor.", "Choose a way, otherwise the image is left out.": "Wähle einen Weg, sonst bleibt das Bild außen vor.",
"Choose images": "Bilder wählen", "Choose images": "Bilder wählen",
"Choose sheet": "Bogen wählen", "Choose sheet": "Bogen wählen",
"Choose the <b>final size</b> for each image. <b>Every format from AI generation</b> is here — biometric passport photo 35 × 45 mm, 2 × 3 to 6 × 9 cm, 9 × 13 to 24 × 30, poster and frame sizes 30 × 40 to 70 × 100, squares, DIN A6–A2 and the aspect ratios (16:9 “The Frame”, 9:16, 3:2, 4:3, 5:4) — or a <b>custom size</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) or <code>4:3/15</code> (ratio 4:3, longer edge 15 cm).": "Je Bild das <b>Endmaß</b> wählen. Es sind <b>alle Formate der KI-Generierung</b> da — biometrisches Passbild 35 × 45 mm, 2 × 3 bis 6 × 9 cm, 9 × 13 bis 24 × 30, Poster- und Rahmenmaße 30 × 40 bis 70 × 100, Quadrate, DIN A6–A2 und die Seitenverhältnisse (16:9 „The Frame\", 9:16, 3:2, 4:3, 5:4) — oder <b>Eigenes Maß</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) oder <code>4:3/15</code> (Verhältnis 4:3, längere Kante 15 cm).", "Choose the <b>final size</b> for each image. <b>Every format from AI generation</b> is here — biometric passport photo 35 × 45 mm, 2 × 3 to 6 × 9 cm, 9 × 13 to 24 × 30, poster and frame sizes 30 × 40 to 70 × 100, squares, DIN A6–A2 and the aspect ratios (16:9 TV, 9:16, 3:2, 4:3, 5:4) — or a <b>custom size</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) or <code>4:3/15</code> (ratio 4:3, longer edge 15 cm).": "Je Bild das <b>Endmaß</b> wählen. Es sind <b>alle Formate der KI-Generierung</b> da — biometrisches Passbild 35 × 45 mm, 2 × 3 bis 6 × 9 cm, 9 × 13 bis 24 × 30, Poster- und Rahmenmaße 30 × 40 bis 70 × 100, Quadrate, DIN A6–A2 und die Seitenverhältnisse (16:9 TV 16:9, 9:16, 3:2, 4:3, 5:4) — oder <b>Eigenes Maß</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) oder <code>4:3/15</code> (Verhältnis 4:3, längere Kante 15 cm).",
"Choose the <b>paper size</b>: DIN A6–A2 including <b>A3+ (329 × 483 mm)</b>, photo paper cuts 9 × 13 to 20 × 30, US Letter/Legal — or a paper size of your own.": "<b>Papierformat</b> wählen: DIN A6–A2 inklusive <b>A3+ (329 × 483 mm)</b>, Fotopapier-Zuschnitte 9 × 13 bis 20 × 30, US Letter/Legal — oder ein eigenes Papiermaß.", "Choose the <b>paper size</b>: DIN A6–A2 including <b>A3+ (329 × 483 mm)</b>, photo paper cuts 9 × 13 to 20 × 30, US Letter/Legal — or a paper size of your own.": "<b>Papierformat</b> wählen: DIN A6–A2 inklusive <b>A3+ (329 × 483 mm)</b>, Fotopapier-Zuschnitte 9 × 13 bis 20 × 30, US Letter/Legal — oder ein eigenes Papiermaß.",
"Choose the <b>quality</b> and where it goes (<b>library</b>, <b>delivery target</b> or both), then <b>Start</b>.": "<b>Qualität</b> und Ziel (<b>Bibliothek</b>, <b>Auslieferungsziel</b> oder beides) wählen, dann <b>Loslegen</b>.", "Choose the <b>quality</b> and where it goes (<b>library</b>, <b>delivery target</b> or both), then <b>Start</b>.": "<b>Qualität</b> und Ziel (<b>Bibliothek</b>, <b>Auslieferungsziel</b> oder beides) wählen, dann <b>Loslegen</b>.",
"Choose what should happen: <b>Clean up</b> (frame, wall, shop interface gone), <b>Cut out</b> (subject free, background transparent), <b>Format</b> (30 × 40 cm or The Frame, for instance), <b>Contour</b> (white sticker edge).": "Wählen, was passieren soll: <b>Bereinigen</b> (Rahmen, Wand, Shop-Oberfläche weg), <b>Freistellen</b> (Motiv frei, Hintergrund transparent), <b>Format</b> (z. B. 30 × 40 cm oder The Frame), <b>Kontur</b> (weißer Stickerrand).", "Choose what should happen: <b>Clean up</b> (frame, wall, shop interface gone), <b>Cut out</b> (subject free, background transparent), <b>Format</b> (30 × 40 cm or a 16:9 TV frame, for instance), <b>Contour</b> (white sticker edge).": "Wählen, was passieren soll: <b>Bereinigen</b> (Rahmen, Wand, Shop-Oberfläche weg), <b>Freistellen</b> (Motiv frei, Hintergrund transparent), <b>Format</b> (z. B. 30 × 40 cm oder TV 16:9), <b>Kontur</b> (weißer Stickerrand).",
"Clean up": "Bereinigen", "Clean up": "Bereinigen",
"Clean up leftovers": "Reste aufräumen", "Clean up leftovers": "Reste aufräumen",
"Clean up screenshots, cut them out, bring them to size.": "Screenshots bereinigen, freistellen, aufs Format bringen.", "Clean up screenshots, cut them out, bring them to size.": "Screenshots bereinigen, freistellen, aufs Format bringen.",
@@ -303,7 +312,6 @@
"Create link": "Link erzeugen", "Create link": "Link erzeugen",
"Create the PDF and choose “Actual size / 100%” in the print dialog. Done.": "PDF erzeugen und im Druckdialog „Tatsächliche Größe / 100 %“ wählen. Fertig.", "Create the PDF and choose “Actual size / 100%” in the print dialog. Done.": "PDF erzeugen und im Druckdialog „Tatsächliche Größe / 100 %“ wählen. Fertig.",
"Credit card 85.6 × 54 mm": "Scheckkarte 85,6 × 54 mm", "Credit card 85.6 × 54 mm": "Scheckkarte 85,6 × 54 mm",
"Cricut sheet size (cm)": "Cricut-Bogenmaß (cm)",
"Crop": "Zuschneiden", "Crop": "Zuschneiden",
"Crop marks as in prepress": "Schnittmarken wie in der Druckvorstufe", "Crop marks as in prepress": "Schnittmarken wie in der Druckvorstufe",
"Crop · {size}": "Zuschnitt · {size}", "Crop · {size}": "Zuschnitt · {size}",
@@ -314,7 +322,7 @@
"Custom size (cm)": "Eigenes Maß (cm)", "Custom size (cm)": "Eigenes Maß (cm)",
"Custom size …": "Eigenes Maß …", "Custom size …": "Eigenes Maß …",
"Cut out": "Freistellen", "Cut out": "Freistellen",
"Cut out + The Frame makes no sense — pick one.": "Freistellen + The Frame ergibt keinen Sinn — bitte eins wählen.", "Cut out + TV 16:9 makes no sense — pick one.": "Freistellen + TV 16:9 ergibt keinen Sinn — bitte eins wählen.",
"Cutting": "Schneiden", "Cutting": "Schneiden",
"Cutting aids": "Schnitthilfen", "Cutting aids": "Schnitthilfen",
"DIN A0 (84.1 × 118.9 cm)": "DIN A0 (84,1 × 118,9 cm)", "DIN A0 (84.1 × 118.9 cm)": "DIN A0 (84,1 × 118,9 cm)",
@@ -329,6 +337,8 @@
"DIN A7–A0, photo paper, US sizes, custom size": "DIN A7–A0, Fotopapier, US-Maße, freies Maß", "DIN A7–A0, photo paper, US sizes, custom size": "DIN A7–A0, Fotopapier, US-Maße, freies Maß",
"DL 9.9 × 21 cm": "DIN lang 9,9 × 21 cm", "DL 9.9 × 21 cm": "DIN lang 9,9 × 21 cm",
"DL landscape 21 × 9.9 cm": "DIN lang quer 21 × 9,9 cm", "DL landscape 21 × 9.9 cm": "DIN lang quer 21 × 9,9 cm",
"DNS ✓ ({address})": "DNS ✓ ({address})",
"DNS ✕ ({error})": "DNS ✕ ({error})",
"Default": "Standard", "Default": "Standard",
"Default delivery target": "Standard-Auslieferungsziel", "Default delivery target": "Standard-Auslieferungsziel",
"Default dpi": "Standard-dpi", "Default dpi": "Standard-dpi",
@@ -416,10 +426,11 @@
"For power users there is also the “/” command menu (/new, /presets, /status) — it is not needed.": "Für Profis gibt es zusätzlich das „/\"-Befehlsmenü (/new, /presets, /status) — nötig ist es nicht.", "For power users there is also the “/” command menu (/new, /presets, /status) — it is not needed.": "Für Profis gibt es zusätzlich das „/\"-Befehlsmenü (/new, /presets, /status) — nötig ist es nicht.",
"For printing always choose 300 dpi and a cm format — the pixel dimensions are right there next to it.": "Für Druck immer 300 dpi und ein cm-Format wählen — die Pixelmaße stehen direkt dabei.", "For printing always choose 300 dpi and a cm format — the pixel dimensions are right there next to it.": "Für Druck immer 300 dpi und ein cm-Format wählen — die Pixelmaße stehen direkt dabei.",
"For that you first need an email address (above).": "Dafür brauchst du zuerst eine E-Mail-Adresse (oben).", "For that you first need an email address (above).": "Dafür brauchst du zuerst eine E-Mail-Adresse (oben).",
"For the format, choose “<b>Crop</b>” or “<b>Extend</b>” — extending generates more scene around the subject (outpainting), which is ideal for The Frame.": "Beim Format „<b>Zuschneiden</b>\" oder „<b>Erweitern</b>\" wählen — Erweitern erzeugt mehr Szene rund ums Motiv (Outpainting), ideal für The Frame.", "For the format, choose “<b>Crop</b>” or “<b>Extend</b>” — extending generates more scene around the subject (outpainting), which is ideal for a wide TV frame.": "Beim Format „<b>Zuschneiden</b>\" oder „<b>Erweitern</b>\" wählen — Erweitern erzeugt mehr Szene rund ums Motiv (Outpainting), ideal für TV 16:9.",
"Force private": "Privat erzwingen", "Force private": "Privat erzwingen",
"Forgot your password": "Passwort vergessen", "Forgot your password": "Passwort vergessen",
"Forgot your password?": "Passwort vergessen?", "Forgot your password?": "Passwort vergessen?",
"Format (optional)": "Format (optional)",
"Format of {name}": "Format von {name}", "Format of {name}": "Format von {name}",
"From the library": "Aus Bibliothek", "From the library": "Aus Bibliothek",
"Gap automatic ({gap} mm — to suit the marks)": "Abstand automatisch ({gap} mm — passend zu den Marken)", "Gap automatic ({gap} mm — to suit the marks)": "Abstand automatisch ({gap} mm — passend zu den Marken)",
@@ -434,14 +445,16 @@
"Generate preview images": "Vorschaubilder erzeugen", "Generate preview images": "Vorschaubilder erzeugen",
"Generate token": "Token erzeugen", "Generate token": "Token erzeugen",
"Generating an alternative …": "Erzeuge Alternative …", "Generating an alternative …": "Erzeuge Alternative …",
"Give every image in the library a unique name?\n\nThe files themselves stay untouched — they are stored under their ID. Copies already placed on the delivery target keep their old name; that is what “Deliver again” is for afterwards.": "Alle Bilder der Bibliothek auf eindeutige Namen bringen?\n\nDie Dateien selbst bleiben unberührt — sie liegen unter ihrer ID im Speicher. Bereits am Auslieferungsziel abgelegte Kopien behalten ihren alten Namen; dafür gibt es danach „Erneut ausliefern\".", "Give every image in the library a unique name?\n\nThe files themselves stay untouched — they are stored under their ID. ": "Alle Bilder der Bibliothek auf eindeutige Namen bringen?\n\nDie Dateien selbst bleiben unberührt — sie liegen unter ihrer ID im Speicher. Bereits am Auslieferungsziel abgelegte Kopien behalten ihren alten Namen; dafür gibt es danach „Erneut ausliefern\".",
"Gives exactly {w} × {h} pixels at 300 dpi — ready to print.": "Ergibt exakt {w} × {h} Pixel bei 300 dpi — druckfertig.", "Gives exactly {w} × {h} pixels at 300 dpi — ready to print.": "Ergibt exakt {w} × {h} Pixel bei 300 dpi — druckfertig.",
"Glue flap (mm)": "Klebefalz (mm)", "Glue flap (mm)": "Klebefalz (mm)",
"Grab the image and drag · double-tap zooms · arrow keys for fine work.": "Bild anfassen und ziehen · Doppeltipp zoomt · Pfeiltasten für Feinarbeit.", "Grab the image and drag · double-tap zooms · arrow keys for fine work.": "Bild anfassen und ziehen · Doppeltipp zoomt · Pfeiltasten für Feinarbeit.",
"Green": "Grün", "Green": "Grün",
"Heading": "Überschrift", "Heading": "Überschrift",
"Help": "Hilfe",
"Help · Klarbild": "Anleitung · Klarbild", "Help · Klarbild": "Anleitung · Klarbild",
"Hide visits": "Zugriffe zu", "Hide visits": "Zugriffe zu",
"Host": "Host",
"How Klarbild works": "So funktioniert Klarbild", "How Klarbild works": "So funktioniert Klarbild",
"If the aspect ratio does not fit, you decide: crop or letterbox. In the crop window you move and zoom the original inside the target frame — with the mouse, a finger, a double-tap or the arrow keys. The crop is stored relatively and survives any resolution.": "Passt das Seitenverhältnis nicht, entscheidest du: zuschneiden oder Rand lassen. Im Zuschnitt-Fenster verschiebst und zoomst du das Original im Zielrahmen — mit Maus, Finger, Doppeltipp oder Pfeiltasten. Der Ausschnitt wird relativ gespeichert und überlebt jede Auflösung.", "If the aspect ratio does not fit, you decide: crop or letterbox. In the crop window you move and zoom the original inside the target frame — with the mouse, a finger, a double-tap or the arrow keys. The crop is stored relatively and survives any resolution.": "Passt das Seitenverhältnis nicht, entscheidest du: zuschneiden oder Rand lassen. Im Zuschnitt-Fenster verschiebst und zoomst du das Original im Zielrahmen — mit Maus, Finger, Doppeltipp oder Pfeiltasten. Der Ausschnitt wird relativ gespeichert und überlebt jede Auflösung.",
"If there is an account for this address, the message is on its way. Have a look in the spam folder too.": "Wenn es zu dieser Adresse ein Konto gibt, ist die Nachricht unterwegs. Schau auch im Spam nach.", "If there is an account for this address, the message is on its way. Have a look in the spam folder too.": "Wenn es zu dieser Adresse ein Konto gibt, ist die Nachricht unterwegs. Schau auch im Spam nach.",
@@ -491,6 +504,7 @@
"Links that let other people look at your images — without an account. Here you can see how often they were opened, and switch them off whenever you want.": "Links, mit denen andere deine Bilder ansehen können — ohne Konto. Hier siehst du, wie oft sie geöffnet wurden, und kannst sie jederzeit abschalten.", "Links that let other people look at your images — without an account. Here you can see how often they were opened, and switch them off whenever you want.": "Links, mit denen andere deine Bilder ansehen können — ohne Konto. Hier siehst du, wie oft sie geöffnet wurden, und kannst sie jederzeit abschalten.",
"Loading …": "Wird geladen …", "Loading …": "Wird geladen …",
"Mailbox password": "Postfach-Passwort", "Mailbox password": "Postfach-Passwort",
"Main navigation": "Hauptnavigation",
"Make the print PDF": "Druck-PDF erzeugen", "Make the print PDF": "Druck-PDF erzeugen",
"Manage all links": "Alle Links verwalten", "Manage all links": "Alle Links verwalten",
"Margin to the paper edge (mm)": "Rand zum Papier (mm)", "Margin to the paper edge (mm)": "Rand zum Papier (mm)",
@@ -514,6 +528,7 @@
"My account · Klarbild": "Mein Konto · Klarbild", "My account · Klarbild": "Mein Konto · Klarbild",
"My account, sharing & email": "Mein Konto, Teilen & E-Mail", "My account, sharing & email": "Mein Konto, Teilen & E-Mail",
"NSFW off": "NSFW aus", "NSFW off": "NSFW aus",
"Name": "Name",
"Name is missing.": "Name fehlt.", "Name is missing.": "Name fehlt.",
"Name of the preset?": "Name des Presets?", "Name of the preset?": "Name des Presets?",
"Name of the preset? (e.g. “Nursery set”)": "Name der Vorlage? (z. B. „Kita-Satz\")", "Name of the preset? (e.g. “Nursery set”)": "Name der Vorlage? (z. B. „Kita-Satz\")",
@@ -588,6 +603,7 @@
"Own border colour": "Eigene Randfarbe", "Own border colour": "Eigene Randfarbe",
"Own instruction (optional)": "Eigene Anweisung (optional)", "Own instruction (optional)": "Eigene Anweisung (optional)",
"Own only": "Nur eigene", "Own only": "Nur eigene",
"PDF": "PDF",
"PDF at original size, 72–1200 dpi": "PDF in Originalgröße, 72–1200 dpi", "PDF at original size, 72–1200 dpi": "PDF in Originalgröße, 72–1200 dpi",
"PDF generated again. When printing, choose “Actual size / 100 %”.": "PDF neu erzeugt. Beim Drucken „Tatsächliche Größe / 100 %\" wählen.", "PDF generated again. When printing, choose “Actual size / 100 %”.": "PDF neu erzeugt. Beim Drucken „Tatsächliche Größe / 100 %\" wählen.",
"PDF ready · {sheets}. When printing, choose “Actual size / 100 %”.": "PDF fertig · {sheets}. Beim Drucken „Tatsächliche Größe / 100 %\" wählen.", "PDF ready · {sheets}. When printing, choose “Actual size / 100 %”.": "PDF fertig · {sheets}. Beim Drucken „Tatsächliche Größe / 100 %\" wählen.",
@@ -634,10 +650,12 @@
"Please give a valid paper size.": "Bitte ein gültiges Papiermaß angeben.", "Please give a valid paper size.": "Bitte ein gültiges Papiermaß angeben.",
"Polaroid · image area 79 × 79 mm": "Polaroid · Bildfläche 79 × 79 mm", "Polaroid · image area 79 × 79 mm": "Polaroid · Bildfläche 79 × 79 mm",
"Polaroid · whole card 88 × 107 mm": "Polaroid · ganze Karte 88 × 107 mm", "Polaroid · whole card 88 × 107 mm": "Polaroid · ganze Karte 88 × 107 mm",
"Port": "Port",
"Port reachable in {ms}ms ✓": "Port erreichbar in {ms}ms ✓", "Port reachable in {ms}ms ✓": "Port erreichbar in {ms}ms ✓",
"Portrait": "Hochformat", "Portrait": "Hochformat",
"Portrait (9:16)": "Hochformat (9:16)", "Portrait (9:16)": "Hochformat (9:16)",
"Postcard 14.8 × 10.5 cm (A6 landscape)": "Postkarte 14,8 × 10,5 cm (A6 quer)", "Postcard 14.8 × 10.5 cm (A6 landscape)": "Postkarte 14,8 × 10,5 cm (A6 quer)",
"Poster": "Poster",
"Poster & frames": "Poster & Rahmen", "Poster & frames": "Poster & Rahmen",
"Preset deleted.": "Preset gelöscht.", "Preset deleted.": "Preset gelöscht.",
"Preset loaded — the image was taken over for every format.": "Vorlage geladen — das Bild wurde für jedes Format übernommen.", "Preset loaded — the image was taken over for every format.": "Vorlage geladen — das Bild wurde für jedes Format übernommen.",
@@ -654,6 +672,7 @@
"Printed last": "Zuletzt gedruckt", "Printed last": "Zuletzt gedruckt",
"Private off": "Privat aus", "Private off": "Privat aus",
"Private session": "Private Session", "Private session": "Private Session",
"Prompt": "Prompt",
"Prompt copied.": "Prompt kopiert.", "Prompt copied.": "Prompt kopiert.",
"Proof": "Nachweis", "Proof": "Nachweis",
"Protected with a passphrase": "Mit Zugangswort geschützt", "Protected with a passphrase": "Mit Zugangswort geschützt",
@@ -687,6 +706,7 @@
"Rotate to save space": "Zum Platzsparen drehen", "Rotate to save space": "Zum Platzsparen drehen",
"Run it yourself": "Selbst betreiben", "Run it yourself": "Selbst betreiben",
"Run “Check library” first — then you can see what would change.": "Erst „Bibliothek prüfen\" — dann siehst du, was sich ändern würde.", "Run “Check library” first — then you can see what would change.": "Erst „Bibliothek prüfen\" — dann siehst du, was sich ändern würde.",
"Running": "Läuft",
"Save": "Speichern", "Save": "Speichern",
"Save as PDF / print": "Als PDF / drucken", "Save as PDF / print": "Als PDF / drucken",
"Save password": "Passwort speichern", "Save password": "Passwort speichern",
@@ -731,6 +751,7 @@
"Share links": "Teilen-Links", "Share links": "Teilen-Links",
"Share links are only valid for a limited time. Whoever sent it to you can create a new one.": "Teilen-Links gelten nur eine begrenzte Zeit. Wer ihn dir geschickt hat, kann einen neuen erzeugen.", "Share links are only valid for a limited time. Whoever sent it to you can create a new one.": "Teilen-Links gelten nur eine begrenzte Zeit. Wer ihn dir geschickt hat, kann einen neuen erzeugen.",
"Share {n} images": "{n} Bilder teilen", "Share {n} images": "{n} Bilder teilen",
"Shared": "Geteilt",
"Shared links": "Geteilte Links", "Shared links": "Geteilte Links",
"Shared links · Klarbild": "Geteilte Links · Klarbild", "Shared links · Klarbild": "Geteilte Links · Klarbild",
"Sharing images, email, forgotten passwords": "Bilder teilen, E-Mail, Passwort vergessen", "Sharing images, email, forgotten passwords": "Bilder teilen, E-Mail, Passwort vergessen",
@@ -743,6 +764,7 @@
"Shown as": "Angezeigt als", "Shown as": "Angezeigt als",
"Sign in": "Anmelden", "Sign in": "Anmelden",
"Sign in · Klarbild": "Anmelden · Klarbild", "Sign in · Klarbild": "Anmelden · Klarbild",
"Sign out": "Abmelden",
"Sign-in failed.": "Anmeldung fehlgeschlagen.", "Sign-in failed.": "Anmeldung fehlgeschlagen.",
"Size not recognised. Examples: “13x18” (cm), “35x45mm”, “4:3/15”.": "Maß nicht erkannt. Beispiele: „13x18\" (cm), „35x45mm\", „4:3/15\".", "Size not recognised. Examples: “13x18” (cm), “35x45mm”, “4:3/15”.": "Maß nicht erkannt. Beispiele: „13x18\" (cm), „35x45mm\", „4:3/15\".",
"Size not recognised. Examples: “21x29.7”, “329x483mm”.": "Maß nicht erkannt. Beispiele: „21x29,7\", „329x483mm\".", "Size not recognised. Examples: “21x29.7”, “329x483mm”.": "Maß nicht erkannt. Beispiele: „21x29,7\", „329x483mm\".",
@@ -766,6 +788,8 @@
"Stickers: choose Cut out + Contour together.": "Sticker: Freistellen + Kontur zusammen wählen.", "Stickers: choose Cut out + Contour together.": "Sticker: Freistellen + Kontur zusammen wählen.",
"Storage & cleanup": "Speicher & Aufräumen", "Storage & cleanup": "Speicher & Aufräumen",
"Stored encrypted, takes precedence over the environment variable.": "Verschlüsselt gespeichert, hat Vorrang vor der Umgebungsvariable.", "Stored encrypted, takes precedence over the environment variable.": "Verschlüsselt gespeichert, hat Vorrang vor der Umgebungsvariable.",
"Studio": "Studio",
"Studio · Klarbild": "Studio · Klarbild",
"Subject only, background transparent.": "Nur das Motiv, Hintergrund transparent.", "Subject only, background transparent.": "Nur das Motiv, Hintergrund transparent.",
"Switch back on": "Wieder freigeben", "Switch back on": "Wieder freigeben",
"Switch off": "Abschalten", "Switch off": "Abschalten",
@@ -778,6 +802,7 @@
"Target created.": "Ziel angelegt.", "Target created.": "Ziel angelegt.",
"Telegram bot": "Telegram-Bot", "Telegram bot": "Telegram-Bot",
"Tell me when a job is finished": "Sag mir Bescheid, wenn ein Auftrag fertig ist", "Tell me when a job is finished": "Sag mir Bescheid, wenn ein Auftrag fertig ist",
"Test": "Testen",
"Test connection": "Verbindung testen", "Test connection": "Verbindung testen",
"Test message": "Testnachricht", "Test message": "Testnachricht",
"Test message to": "Testnachricht an", "Test message to": "Testnachricht an",
@@ -839,7 +864,7 @@
"Unique file names — images no longer overwrite each other": "Eindeutige Dateinamen — Bilder überschreiben sich nicht mehr", "Unique file names — images no longer overwrite each other": "Eindeutige Dateinamen — Bilder überschreiben sich nicht mehr",
"Unit": "Einheit", "Unit": "Einheit",
"Unlink": "Trennen", "Unlink": "Trennen",
"Upload all already delivered images again — under the new name?\n\nThe old files stay on the target and have to be removed there by hand. This can take a while.": "Alle bereits ausgelieferten Bilder erneut hochladen — unter dem neuen Namen?\n\nDie alten Dateien bleiben am Ziel liegen und müssen dort von Hand entfernt werden. Das kann dauern.", "Upload all already delivered images again — under the new name?\n\n": "Alle bereits ausgelieferten Bilder erneut hochladen — unter dem neuen Namen?\n\nDie alten Dateien bleiben am Ziel liegen und müssen dort von Hand entfernt werden. Das kann dauern.",
"Upload failed": "Upload fehlgeschlagen", "Upload failed": "Upload fehlgeschlagen",
"Upload images (drag them in, ⌘/Ctrl + V) — or <b>Choose from the library</b>: everything Klarbild has already produced, with search, by folder and several at once. No more detour through another upload.": "Bilder hochladen (ziehen, ⌘/Strg + V) — oder <b>Aus der Bibliothek wählen</b>: alles, was Klarbild schon erzeugt hat, mit Suche, nach Ordner und mehrere auf einmal. Kein Umweg mehr über einen erneuten Upload.", "Upload images (drag them in, ⌘/Ctrl + V) — or <b>Choose from the library</b>: everything Klarbild has already produced, with search, by folder and several at once. No more detour through another upload.": "Bilder hochladen (ziehen, ⌘/Strg + V) — oder <b>Aus der Bibliothek wählen</b>: alles, was Klarbild schon erzeugt hat, mit Suche, nach Ordner und mehrere auf einmal. Kein Umweg mehr über einen erneuten Upload.",
"Upload screenshots — drag them in, paste with ⌘/Ctrl + V or tap (up to 100 at once).": "Screenshots hochladen — ziehen, mit ⌘/Strg + V einfügen oder tippen (bis zu 100 auf einmal).", "Upload screenshots — drag them in, paste with ⌘/Ctrl + V or tap (up to 100 at once).": "Screenshots hochladen — ziehen, mit ⌘/Strg + V einfügen oder tippen (bis zu 100 auf einmal).",
@@ -914,7 +939,7 @@
"e.g. 5 (=5×5 cm) or 5x7": "z. B. 5 (=5×5 cm) oder 5x7", "e.g. 5 (=5×5 cm) or 5x7": "z. B. 5 (=5×5 cm) oder 5x7",
"e.g. Ada in the garden": "z. B. Ada im Garten", "e.g. Ada in the garden": "z. B. Ada im Garten",
"e.g. Nursery photos August": "z. B. Kita-Fotos August", "e.g. Nursery photos August": "z. B. Kita-Fotos August",
"e.g. The Frame FTP": "z. B. The Frame FTP", "e.g. Studio archive": "z. B. TV 16:9 FTP",
"e.g. mirror.local or DDNS": "z. B. spiegel.local oder DDNS", "e.g. mirror.local or DDNS": "z. B. spiegel.local oder DDNS",
"e.g. smtp.example.com": "z. B. smtp.example.com", "e.g. smtp.example.com": "z. B. smtp.example.com",
"e.g. “A minimalist poster with an olive branch on a sand-coloured ground.”": "z. B. „Ein minimalistisches Poster mit einem Olivenzweig auf sandfarbenem Grund.“", "e.g. “A minimalist poster with an olive branch on a sand-coloured ground.”": "z. B. „Ein minimalistisches Poster mit einem Olivenzweig auf sandfarbenem Grund.“",
@@ -940,6 +965,7 @@
"off": "aus", "off": "aus",
"only {dpi} dpi — goes slightly soft": "nur {dpi} dpi — wird leicht weich", "only {dpi} dpi — goes slightly soft": "nur {dpi} dpi — wird leicht weich",
"only {dpi} dpi — goes visibly blurred, take a smaller format or a bigger image": "nur {dpi} dpi — wird sichtbar unscharf, kleineres Format oder größeres Bild nehmen", "only {dpi} dpi — goes visibly blurred, take a smaller format or a bigger image": "nur {dpi} dpi — wird sichtbar unscharf, kleineres Format oder größeres Bild nehmen",
"optional": "optional",
"or": "oder", "or": "oder",
"or send it straight from here": "oder direkt von hier verschicken", "or send it straight from here": "oder direkt von hier verschicken",
"own text": "eigener Text", "own text": "eigener Text",
@@ -965,6 +991,7 @@
"top centre": "oben mittig", "top centre": "oben mittig",
"top left": "oben links", "top left": "oben links",
"top right": "oben rechts", "top right": "oben rechts",
"transparent": "transparent",
"valid until {date}": "gültig bis {date}", "valid until {date}": "gültig bis {date}",
"view only": "nur ansehen", "view only": "nur ansehen",
"views": "Aufrufe", "views": "Aufrufe",
@@ -1025,9 +1052,10 @@
"{sizes} on a usable {usable}.": "{sizes} bei nutzbaren {usable}.", "{sizes} on a usable {usable}.": "{sizes} bei nutzbaren {usable}.",
"{size} = {w} × {h} px": "{size} = {w} × {h} px", "{size} = {w} × {h} px": "{size} = {w} × {h} px",
"{size} does not fit on {paper}.": "{size} passt nicht auf {paper}.", "{size} does not fit on {paper}.": "{size} passt nicht auf {paper}.",
"{title} · Klarbild": "{title} · Klarbild",
"“Backup” targets automatically receive all results (like the backup mirror).": "„Backup\"-Ziele erhalten automatisch alle Ergebnisse (wie der Sicherungsspiegel).", "“Backup” targets automatically receive all results (like the backup mirror).": "„Backup\"-Ziele erhalten automatisch alle Ergebnisse (wie der Sicherungsspiegel).",
"“Deliver again” uploads every already delivered image to the target again under the new name. The old files stay there — deleting something at the client automatically would be the wrong reflex; you do that on the delivery target yourself.": "„Erneut ausliefern\" lädt alle schon ausgelieferten Bilder unter dem neuen Namen erneut zum Ziel hoch. Die alten Dateien bleiben dort liegen — automatisch etwas beim Kunden zu löschen wäre der falsche Reflex; das machst du am Auslieferungsziel selbst.", "“Deliver again” uploads every already delivered image to the target again under the new name. The old files stay there — deleting something at the client automatically would be the wrong reflex; you do that on the delivery target yourself.": "„Erneut ausliefern\" lädt alle schon ausgelieferten Bilder unter dem neuen Namen erneut zum Ziel hoch. Die alten Dateien bleiben dort liegen — automatisch etwas beim Kunden zu löschen wäre der falsche Reflex; das machst du am Auslieferungsziel selbst.",
"“Extend” produces more scene around the subject (outpainting) instead of cropping — made for The Frame.": "„Erweitern\" erzeugt mehr Szene rund ums Motiv (Outpainting), statt zuzuschneiden — ideal für The Frame.", "“Extend” produces more scene around the subject (outpainting) instead of cropping — made for wide TV frames.": "„Erweitern\" erzeugt mehr Szene rund ums Motiv (Outpainting), statt zuzuschneiden — ideal für TV 16:9.",
"“Passport photos” becomes “Print” — 86 image sizes, crop repaired": "Aus „Passbilder\" wird „Drucken\" — 86 Bildmaße, Zuschnitt repariert", "“Passport photos” becomes “Print” — 86 image sizes, crop repaired": "Aus „Passbilder\" wird „Drucken\" — 86 Bildmaße, Zuschnitt repariert",
"“Show folder” lists what actually sits where over SFTP.": "„Ordner anzeigen\" listet, was per SFTP tatsächlich wo liegt.", "“Show folder” lists what actually sits where over SFTP.": "„Ordner anzeigen\" listet, was per SFTP tatsächlich wo liegt.",
"“Test connection” only checks the login. Only the test message proves that the mail really arrives — look in the spam folder too.": "„Verbindung testen\" prüft nur die Anmeldung. Erst die Testnachricht beweist, dass die Mail wirklich ankommt — schau auch im Spam-Ordner nach.", "“Test connection” only checks the login. Only the test message proves that the mail really arrives — look in the spam folder too.": "„Verbindung testen\" prüft nur die Anmeldung. Erst die Testnachricht beweist, dass die Mail wirklich ankommt — schau auch im Spam-Ordner nach.",
+1 -1
View File
@@ -5,7 +5,7 @@ import { one } from './lib/db';
import { LOCALE_COOKIE, resolveLocale, isLocale } from './lib/i18n'; import { LOCALE_COOKIE, resolveLocale, isLocale } from './lib/i18n';
const PUBLIC_PATHS = [ const PUBLIC_PATHS = [
/^\/login/, /^\/tour/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/login/, /^\/tour/, /^\/api\/auth\/login/, /^\/api\/health/,
/^\/api\/telegram\/webhook/, /^\/llms\.txt/, /^\/api\/telegram\/webhook/, /^\/llms\.txt/,
// Forgot password — must be reachable without being signed in, otherwise it's pointless. // Forgot password — must be reachable without being signed in, otherwise it's pointless.
/^\/password/, /^\/api\/auth\/reset/, /^\/password/, /^\/api\/auth\/reset/,
+2 -2
View File
@@ -113,7 +113,7 @@ const dict = dictFor(locale);
<ul> <ul>
<li set:html={t('<b>Combine:</b> several images plus a description into one new image (for example “… with our dog”).')} /> <li set:html={t('<b>Combine:</b> several images plus a description into one new image (for example “… with our dog”).')} />
<li set:html={t('<b>Generate:</b> images from text alone, with no source image at all.')} /> <li set:html={t('<b>Generate:</b> images from text alone, with no source image at all.')} />
<li set:html={t('<b>The Frame “Extend”:</b> generate more scene around the subject (outpainting) instead of only cropping.')} /> <li set:html={t('<b>TV 16:9 “Extend”:</b> generate more scene around the subject (outpainting) instead of only cropping.')} />
<li set:html={t('<b>Keep editing:</b> take a finished image as a new source and edit it again.')} /> <li set:html={t('<b>Keep editing:</b> take a finished image as a new source and edit it again.')} />
<li set:html={t('<b>Alternatives:</b> generate further versions of the same image — grouped, with a switcher.')} /> <li set:html={t('<b>Alternatives:</b> generate further versions of the same image — grouped, with a switcher.')} />
<li set:html={t('<b>Folders:</b> filter, rename and delete in the library; each folder can have its own folder on the target.')} /> <li set:html={t('<b>Folders:</b> filter, rename and delete in the library; each folder can have its own folder on the target.')} />
@@ -132,7 +132,7 @@ const dict = dictFor(locale);
<li set:html={t('<b>Prompt display:</b> every generated image shows its prompt in the library (with a copy button).')} /> <li set:html={t('<b>Prompt display:</b> every generated image shows its prompt in the library (with a copy button).')} />
<li set:html={t('<b>Manage preset recipes yourself:</b> save the current settings as a recipe, load it, delete it.')} /> <li set:html={t('<b>Manage preset recipes yourself:</b> save the current settings as a recipe, load it, delete it.')} />
<li set:html={t('<b>API token & MCP server:</b> external access — hand images from a folder or from iMessage to Klarbild through an assistant.')} /> <li set:html={t('<b>API token & MCP server:</b> external access — hand images from a folder or from iMessage to Klarbild through an assistant.')} />
<li set:html={t('<b>The Frame</b> now delivers automatically into the folder <b>“TheFrame-Backgrounds”</b> on the target.')} /> <li set:html={t('<b>TV 16:9</b> now delivers automatically into the folder <b>“TheFrame-Backgrounds”</b> on the target.')} />
<li set:html={t('<b>A second backup (the mirror):</b> every result additionally goes to the backup mirror over SFTP/FTPS (Tailscale works too) — with a “Mirror ✓” marker and “Back up all images to the mirror”.')} /> <li set:html={t('<b>A second backup (the mirror):</b> every result additionally goes to the backup mirror over SFTP/FTPS (Tailscale works too) — with a “Mirror ✓” marker and “Back up all images to the mirror”.')} />
<li set:html={t('<b>Storage management:</b> thumbnails, deleting sources, an automatic retention period.')} /> <li set:html={t('<b>Storage management:</b> thumbnails, deleting sources, an automatic retention period.')} />
<li set:html={t('<b>Telegram entirely button-driven:</b> a permanent keyboard & menu — Edit, Combine, “✨ New image”, Keep editing, with no commands at all.')} /> <li set:html={t('<b>Telegram entirely button-driven:</b> a permanent keyboard & menu — Edit, Combine, “✨ New image”, Keep editing, with no commands at all.')} />
+3 -3
View File
@@ -21,8 +21,8 @@ const dict = dictFor(locale);
<ol> <ol>
<li set:html={t('Choose <b>Edit</b> at the top of the <b>studio</b>.')} /> <li set:html={t('Choose <b>Edit</b> at the top of the <b>studio</b>.')} />
<li>{t('Upload screenshots — drag them in, paste with ⌘/Ctrl + V or tap (up to 100 at once).')}</li> <li>{t('Upload screenshots — drag them in, paste with ⌘/Ctrl + V or tap (up to 100 at once).')}</li>
<li set:html={t('Choose what should happen: <b>Clean up</b> (frame, wall, shop interface gone), <b>Cut out</b> (subject free, background transparent), <b>Format</b> (30 × 40 cm or The Frame, for instance), <b>Contour</b> (white sticker edge).')} /> <li set:html={t('Choose what should happen: <b>Clean up</b> (frame, wall, shop interface gone), <b>Cut out</b> (subject free, background transparent), <b>Format</b> (30 × 40 cm or a 16:9 TV frame, for instance), <b>Contour</b> (white sticker edge).')} />
<li set:html={t('For the format, choose “<b>Crop</b>” or “<b>Extend</b>” — extending generates more scene around the subject (outpainting), which is ideal for The Frame.')} /> <li set:html={t('For the format, choose “<b>Crop</b>” or “<b>Extend</b>” — extending generates more scene around the subject (outpainting), which is ideal for a wide TV frame.')} />
<li set:html={t('Choose the <b>quality</b> and where it goes (<b>library</b>, <b>delivery target</b> or both), then <b>Start</b>.')} /> <li set:html={t('Choose the <b>quality</b> and where it goes (<b>library</b>, <b>delivery target</b> or both), then <b>Start</b>.')} />
</ol> </ol>
</section> </section>
@@ -55,7 +55,7 @@ const dict = dictFor(locale);
<p class="lead" set:html={t('The <b>Print</b> tab is the photo-lab part of Klarbild: no model, no cost, no waiting. Only cropping, scaling and geometry — from the biometric passport photo to the 30 × 40 poster.')} /> <p class="lead" set:html={t('The <b>Print</b> tab is the photo-lab part of Klarbild: no model, no cost, no waiting. Only cropping, scaling and geometry — from the biometric passport photo to the 30 × 40 poster.')} />
<ol> <ol>
<li set:html={t('Upload images (drag them in, ⌘/Ctrl + V) — or <b>Choose from the library</b>: everything Klarbild has already produced, with search, by folder and several at once. No more detour through another upload.')} /> <li set:html={t('Upload images (drag them in, ⌘/Ctrl + V) — or <b>Choose from the library</b>: everything Klarbild has already produced, with search, by folder and several at once. No more detour through another upload.')} />
<li set:html={t('Choose the <b>final size</b> for each image. <b>Every format from AI generation</b> is here — biometric passport photo 35 × 45 mm, 2 × 3 to 6 × 9 cm, 9 × 13 to 24 × 30, poster and frame sizes 30 × 40 to 70 × 100, squares, DIN A6–A2 and the aspect ratios (16:9 “The Frame”, 9:16, 3:2, 4:3, 5:4) — or a <b>custom size</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) or <code>4:3/15</code> (ratio 4:3, longer edge 15 cm).')} /> <li set:html={t('Choose the <b>final size</b> for each image. <b>Every format from AI generation</b> is here — biometric passport photo 35 × 45 mm, 2 × 3 to 6 × 9 cm, 9 × 13 to 24 × 30, poster and frame sizes 30 × 40 to 70 × 100, squares, DIN A6–A2 and the aspect ratios (16:9 TV, 9:16, 3:2, 4:3, 5:4) — or a <b>custom size</b>: <code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) or <code>4:3/15</code> (ratio 4:3, longer edge 15 cm).')} />
<li set:html={t('<b>If the image does not fit the format</b>, you decide for each image: <b>Crop</b> (the format is filled, something is missing at the edges — the default) or <b>Letterbox</b> (the whole image stays visible, with the <b>border colour</b> around it — white, black, paper-coloured or freely chosen). <b>Nothing is ever distorted.</b>')} /> <li set:html={t('<b>If the image does not fit the format</b>, you decide for each image: <b>Crop</b> (the format is filled, something is missing at the edges — the default) or <b>Letterbox</b> (the whole image stays visible, with the <b>border colour</b> around it — white, black, paper-coloured or freely chosen). <b>Nothing is ever distorted.</b>')} />
<li set:html={t('Tap the preview image → <b>Crop</b>: pan and zoom. The aspect ratio stays locked to the target format, and nothing is ever distorted.')} /> <li set:html={t('Tap the preview image → <b>Crop</b>: pan and zoom. The aspect ratio stays locked to the target format, and nothing is ever distorted.')} />
<li set:html={t('Set the <b>quantity</b> per image — that is how the passport-photo or school photo set comes about.')} /> <li set:html={t('Set the <b>quantity</b> per image — that is how the passport-photo or school photo set comes about.')} />
+4 -1
View File
@@ -3,13 +3,16 @@ import Base from '../layouts/Base.astro';
import StudioApp from '../components/StudioApp.tsx'; import StudioApp from '../components/StudioApp.tsx';
import { query } from '../lib/db'; import { query } from '../lib/db';
import { has } from '../lib/modules'; import { has } from '../lib/modules';
import { tFor, dictFor } from '../lib/i18n.server';
// Without the AI module there is no studio, and the print studio is the product. // Without the AI module there is no studio, and the print studio is the product.
if (!has('ai')) return Astro.redirect('/print', 302); if (!has('ai')) return Astro.redirect('/print', 302);
const locale = Astro.locals.locale ?? 'en'; const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
const recipes = await query('SELECT * FROM recipes ORDER BY is_default DESC, name').catch(() => []); const recipes = await query('SELECT * FROM recipes ORDER BY is_default DESC, name').catch(() => []);
--- ---
<Base title="Studio · Klarbild"> <Base title={t('Studio · Klarbild')}>
<StudioApp client:load recipes={recipes} locale={locale} dict={dict} /> <StudioApp client:load recipes={recipes} locale={locale} dict={dict} />
</Base> </Base>
+3
View File
@@ -1,7 +1,10 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { has } from '../lib/modules';
import LibraryApp from '../components/LibraryApp.tsx'; import LibraryApp from '../components/LibraryApp.tsx';
import { tFor, dictFor } from '../lib/i18n.server'; import { tFor, dictFor } from '../lib/i18n.server';
// A page of a module that is off does not exist for this instance.
if (!has('ai')) return Astro.redirect('/print', 302);
const locale = Astro.locals.locale ?? 'en'; const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale); const t = tFor(locale);
const dict = dictFor(locale); const dict = dictFor(locale);
+4 -4
View File
@@ -46,7 +46,7 @@ world such a module does not exist.
## Formats (output_format) ## Formats (output_format)
- Fixed keys: 9x13,10x15,13x18,15x20,20x30,30x40,30x45,40x50,40x60,50x70,60x90, - Fixed keys: 9x13,10x15,13x18,15x20,20x30,30x40,30x45,40x50,40x60,50x70,60x90,
A4,A3,A2,20x20,30x30, theframe (3840x2160), portrait916 (2160x3840), keep (keep the original). A4,A3,A2,20x20,30x30, tv169 (3840x2160), portrait916 (2160x3840), keep (keep the original).
- Custom sizes without a preset: \`WIDTHxHEIGHT\` in cm (e.g. \`25x35\`) or \`sticker<N>\` - Custom sizes without a preset: \`WIDTHxHEIGHT\` in cm (e.g. \`25x35\`) or \`sticker<N>\`
for N×N cm (e.g. \`sticker5\` = 5×5 cm). Limit 300 cm. Exact pixels: round(cm/2.54*dpi). for N×N cm (e.g. \`sticker5\` = 5×5 cm). Limit 300 cm. Exact pixels: round(cm/2.54*dpi).
- File format (output_ext): \`png\` (lossless, transparency) or \`jpg\` (small, easy on the - File format (output_ext): \`png\` (lossless, transparency) or \`jpg\` (small, easy on the
@@ -81,7 +81,7 @@ Purely local geometry: cropping (sharp) plus PDF (pdf-lib). No model, no cost.
S9x13,S10x15,S11x15,S12x15,S13x18,S15x20,S18x24,S20x25,S20x30,S24x30 · S9x13,S10x15,S11x15,S12x15,S13x18,S15x20,S18x24,S20x25,S20x30,S24x30 ·
R30x40,R30x45,R40x50,R40x60,R50x70,R60x80,R60x90,R70x100 (poster and frame) · R30x40,R30x45,R40x50,R40x60,R50x70,R60x80,R60x90,R70x100 (poster and frame) ·
Q10x10,Q13x13,Q20x20,Q30x30 · DA6,DA5,DA4,DA3,DA2 · Q10x10,Q13x13,Q20x20,Q30x30 · DA6,DA5,DA4,DA3,DA2 ·
W16x9,W9x16,W3x2,W4x3,W5x4 (aspect ratios as a print size, The Frame among them). W16x9,W9x16,W3x2,W4x3,W5x4 (aspect ratios as a print size, a 16:9 TV frame among them).
So every format the AI generation offers (src/lib/format.ts) can be printed here too. So every format the AI generation offers (src/lib/format.ts) can be printed here too.
- If the image does not match the size (fit, per cell): \`cover\` = crop until it is filled (the default, - If the image does not match the size (fit, per cell): \`cover\` = crop until it is filled (the default,
nothing stays empty, something is lost at the edges) or \`contain\` = letterbox the whole image, the nothing stays empty, something is lost at the edges) or \`contain\` = letterbox the whole image, the
@@ -178,7 +178,7 @@ Results carry IPTC \`DigitalSourceType\` as XMP inside the file:
Readable with exiftool. The print module creates nothing with AI and therefore labels nothing. Readable with exiftool. The print module creates nothing with AI and therefore labels nothing.
## File names of the results ## File names of the results
Scheme: \`YYYY-MM-DD_HHMMSS_<motif>_<format>_<short id>.<ext>\` — e.g. \`2026-08-20_143207_team-badge_the-frame_a3f9.jpg\`. Scheme: \`YYYY-MM-DD_HHMMSS_<motif>_<format>_<short id>.<ext>\` — e.g. \`2026-08-20_143207_team-badge_tv169_a3f9.jpg\`.
- The timestamp comes first, so any file listing (the delivery target, the backup mirror, a file - The timestamp comes first, so any file listing (the delivery target, the backup mirror, a file
manager) sorts chronologically by itself. manager) sorts chronologically by itself.
- The four-character short id comes from the item id. That way two images are **never** named the - The four-character short id comes from the item id. That way two images are **never** named the
@@ -195,7 +195,7 @@ Scheme: \`YYYY-MM-DD_HHMMSS_<motif>_<format>_<short id>.<ext>\` — e.g. \`2026-
## Delivery ## Delivery
- A delivery target is an SFTP/FTPS server; the folder on the target is a subfolder under the base path. - A delivery target is an SFTP/FTPS server; the folder on the target is a subfolder under the base path.
The full path is base folder + folder (only assembled when delivering). The default folder is The full path is base folder + folder (only assembled when delivering). The default folder is
"Gallery A"; "The Frame" → "TheFrame-Backgrounds". "Gallery A"; "TV 16:9" → "TV-Backgrounds".
- What is delivered is the result in the chosen file format (output_ext, global or per job) — PNG or - What is delivered is the result in the chosen file format (output_ext, global or per job) — PNG or
JPG. Cut-out motifs stay PNG. JPG. Cut-out motifs stay PNG.
- An optional .md metadata sidecar file can be switched on per target (delivery target, backup mirror, - An optional .md metadata sidecar file can be switched on per target (delivery target, backup mirror,
+2 -1
View File
@@ -2,6 +2,7 @@
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import PrintApp from '../components/PrintApp.tsx'; import PrintApp from '../components/PrintApp.tsx';
import { tFor, dictFor } from '../lib/i18n.server'; import { tFor, dictFor } from '../lib/i18n.server';
import { has } from '../lib/modules';
const locale = Astro.locals.locale ?? 'en'; const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale); const t = tFor(locale);
const dict = dictFor(locale); const dict = dictFor(locale);
@@ -11,7 +12,7 @@ const dict = dictFor(locale);
<p class="page-lead"> <p class="page-lead">
{t('No AI: bring images to exact sizes and place several of them at 100% size with crop marks on one sheet — from the biometric passport photo through the school photo set to the 30 × 40 poster.')} {t('No AI: bring images to exact sizes and place several of them at 100% size with crop marks on one sheet — from the biometric passport photo through the school photo set to the 30 × 40 poster.')}
</p> </p>
<PrintApp client:load locale={locale} dict={dict} /> <PrintApp client:load locale={locale} dict={dict} canDeliver={has('delivery') || has('mirror')} />
<style> <style>
.page-title{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;} .page-title{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
.page-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;} .page-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}
+3
View File
@@ -1,7 +1,10 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { has } from '../lib/modules';
import QueueApp from '../components/QueueApp.tsx'; import QueueApp from '../components/QueueApp.tsx';
import { tFor, dictFor } from '../lib/i18n.server'; import { tFor, dictFor } from '../lib/i18n.server';
// A page of a module that is off does not exist for this instance.
if (!has('ai')) return Astro.redirect('/print', 302);
const locale = Astro.locals.locale ?? 'en'; const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale); const t = tFor(locale);
const dict = dictFor(locale); const dict = dictFor(locale);
+3
View File
@@ -1,7 +1,10 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { has } from '../lib/modules';
import SharesApp from '../components/SharesApp'; import SharesApp from '../components/SharesApp';
import { tFor, dictFor } from '../lib/i18n.server'; import { tFor, dictFor } from '../lib/i18n.server';
// A page of a module that is off does not exist for this instance.
if (!has('share')) return Astro.redirect('/print', 302);
const locale = Astro.locals.locale ?? 'en'; const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale); const t = tFor(locale);
const dict = dictFor(locale); const dict = dictFor(locale);
+15 -15
View File
@@ -8,11 +8,11 @@ const ID2 = '77b21e00-1111-2222-3333-444455556666';
test('The reported bug: two badges on the same day no longer overwrite each other', () => { test('The reported bug: two badges on the same day no longer overwrite each other', () => {
// Same motif, same size, same second — this used to produce the same name twice. // Same motif, same size, same second — this used to produce the same name twice.
const a = buildResultFilename('Portrait Badge.png', 'the-frame', 'jpg', { at: AT, id: ID1 }); const a = buildResultFilename('Portrait Badge.png', 'tv169', 'jpg', { at: AT, id: ID1 });
const b = buildResultFilename('Portrait Badge.png', 'the-frame', 'jpg', { at: AT, id: ID2 }); const b = buildResultFilename('Portrait Badge.png', 'tv169', 'jpg', { at: AT, id: ID2 });
assert.notEqual(a, b, 'two images must never carry the same name'); assert.notEqual(a, b, 'two images must never carry the same name');
assert.equal(a, '2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg'); assert.equal(a, '2026-08-20_143207_portrait-badge_tv169_a3f9.jpg');
assert.equal(b, '2026-08-20_143207_portrait-badge_the-frame_77b2.jpg'); assert.equal(b, '2026-08-20_143207_portrait-badge_tv169_77b2.jpg');
}); });
test('Names sort lexicographically = chronologically', () => { test('Names sort lexicographically = chronologically', () => {
@@ -42,12 +42,12 @@ test('Midnight is written as 00, not as 24', () => {
test('The short id is stable — a second run gives the same name', () => { test('The short id is stable — a second run gives the same name', () => {
assert.equal(shortTag(ID1), shortTag(ID1)); assert.equal(shortTag(ID1), shortTag(ID1));
assert.equal(shortTag(ID1), 'a3f9'); assert.equal(shortTag(ID1), 'a3f9');
const it = { id: ID1, filename: '2026-07-23_portrait_the-frame.png', created_at: AT }; const it = { id: ID1, filename: '2026-07-23_portrait_tv169.png', created_at: AT };
assert.equal(newNameFor(it), newNameFor(it)); assert.equal(newNameFor(it), newNameFor(it));
}); });
test('Processing an image again does not stack up stamps', () => { test('Processing an image again does not stack up stamps', () => {
const once = buildResultFilename('Portrait Badge.png', 'the-frame', 'jpg', { at: AT, id: ID1 }); const once = buildResultFilename('Portrait Badge.png', 'tv169', 'jpg', { at: AT, id: ID1 });
const twice = buildResultFilename(once, '30x40', 'jpg', { at: AT, id: ID2 }); const twice = buildResultFilename(once, '30x40', 'jpg', { at: AT, id: ID2 });
assert.equal(twice, '2026-08-20_143207_portrait-badge_30x40_77b2.jpg'); assert.equal(twice, '2026-08-20_143207_portrait-badge_30x40_77b2.jpg');
const thrice = buildResultFilename(twice, 'a4', 'jpg', { at: AT, id: ID1 }); const thrice = buildResultFilename(twice, 'a4', 'jpg', { at: AT, id: ID1 });
@@ -55,26 +55,26 @@ test('Processing an image again does not stack up stamps', () => {
}); });
test('Our own pattern recognises our own names and only those', () => { test('Our own pattern recognises our own names and only those', () => {
assert.ok(KLARBILD_NAME.test('2026-08-20_143207_portrait-badge_the-frame_a3f9')); assert.ok(KLARBILD_NAME.test('2026-08-20_143207_portrait-badge_tv169_a3f9'));
assert.ok(KLARBILD_NAME.test('2026-08-20_143207_portrait_30x40_a3f9-2')); assert.ok(KLARBILD_NAME.test('2026-08-20_143207_portrait_30x40_a3f9-2'));
assert.ok(!KLARBILD_NAME.test('2026-07-23_portrait_the-frame')); // the old scheme assert.ok(!KLARBILD_NAME.test('2026-07-23_portrait_tv169')); // the old scheme
assert.ok(!KLARBILD_NAME.test('holiday-2026_beach')); // somebody else's name assert.ok(!KLARBILD_NAME.test('holiday-2026_beach')); // somebody else's name
}); });
test('Old names are split up correctly', () => { test('Old names are split up correctly', () => {
assert.deepEqual(splitOldName('2026-07-23_portrait_the-frame.png'), { motif: 'portrait', format: 'the-frame', ext: 'png' }); assert.deepEqual(splitOldName('2026-07-23_portrait_tv169.png'), { motif: 'portrait', format: 'tv169', ext: 'png' });
assert.deepEqual(splitOldName('2026-07-23_klarbild_30x40.jpg'), { motif: 'klarbild', format: '30x40', ext: 'jpg' }); assert.deepEqual(splitOldName('2026-07-23_klarbild_30x40.jpg'), { motif: 'klarbild', format: '30x40', ext: 'jpg' });
// A foreign file without our scheme: all of it becomes the motif, format "original". // A foreign file without our scheme: all of it becomes the motif, format "original".
assert.deepEqual(splitOldName('Holiday Crete.JPEG'), { motif: 'holiday-crete', format: 'original', ext: 'jpeg' }); assert.deepEqual(splitOldName('Holiday Crete.JPEG'), { motif: 'holiday-crete', format: 'original', ext: 'jpeg' });
// Already in the new scheme: motif and format stay, so that renaming is idempotent. // Already in the new scheme: motif and format stay, so that renaming is idempotent.
assert.deepEqual(splitOldName('2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg'), assert.deepEqual(splitOldName('2026-08-20_143207_portrait-badge_tv169_a3f9.jpg'),
{ motif: 'portrait-badge', format: 'the-frame', ext: 'jpg' }); { motif: 'portrait-badge', format: 'tv169', ext: 'jpg' });
}); });
test('Renaming is idempotent — a second run changes nothing', () => { test('Renaming is idempotent — a second run changes nothing', () => {
const old = { id: ID1, filename: '2026-07-23_portrait_the-frame.png', created_at: new Date('2026-07-23T09:15:42Z') }; const old = { id: ID1, filename: '2026-07-23_portrait_tv169.png', created_at: new Date('2026-07-23T09:15:42Z') };
const fresh = newNameFor(old); const fresh = newNameFor(old);
assert.equal(fresh, '2026-07-23_111542_portrait_the-frame_a3f9.png'); assert.equal(fresh, '2026-07-23_111542_portrait_tv169_a3f9.png');
assert.equal(newNameFor({ ...old, filename: fresh }), fresh, 'the second run has to give the same name'); assert.equal(newNameFor({ ...old, filename: fresh }), fresh, 'the second run has to give the same name');
}); });
@@ -144,9 +144,9 @@ test('Folder and file names cannot leave the base folder', () => {
}); });
test('Real customer folders stay allowed — the check is not there to annoy', () => { test('Real customer folders stay allowed — the check is not there to annoy', () => {
const good = ['Gallery A', 'TheFrame-Backgrounds', 'Müller & Söhne (2026)', const good = ['Gallery A', 'TV-Backgrounds', 'Müller & Söhne (2026)',
"Wedding O'Brien", 'Daycare 2026_Group 3', 'a.b.c', "Wedding O'Brien", 'Daycare 2026_Group 3', 'a.b.c',
'2026-08-20_143207_portrait-badge_the-frame_a3f9.jpg']; '2026-08-20_143207_portrait-badge_tv169_a3f9.jpg'];
for (const g of good) assert.equal(safeSegment(g, 'folder'), g, `wrongly rejected: ${g}`); for (const g of good) assert.equal(safeSegment(g, 'folder'), g, `wrongly rejected: ${g}`);
}); });