Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0798eb7438 | ||
|
|
4ff9f8b7a0 |
No files matched your search
+13
-7
@@ -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=
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -7,3 +7,6 @@ dist
|
|||||||
*.log
|
*.log
|
||||||
uploads/
|
uploads/
|
||||||
data/
|
data/
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
+3
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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'] },
|
||||||
|
|||||||
File renamed without changes.
Generated
+1627
-3853
File diff suppressed because it is too large.
Load diff
+5
-6
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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' },
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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/,
|
||||||
|
|||||||
@@ -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.')} />
|
||||||
|
|||||||
@@ -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.')} />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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;}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user