Compare commits

..

47 Commits

Author SHA1 Message Date
till 5ebe8026e0 feat: oversize sheets, image duplication, free labelling and a new layout
Prints larger than the sheet no longer just fail. Each cell can now spill over
the whole sheet (20x30 on A4 loses exactly 3 mm, and says so beforehand) or be
tiled across several sheets with a dashed glue fold and a sheet number. The
maths lives in printoversize.ts, dependency-free, so the browser preview and the
server PDF compute the same thing -- including the note strip that keeps the
sheet number off the picture.

The same image can now appear as several cells, so a portrait can be printed at
two sizes on one sheet without copying and re-uploading the file. The footer
line can be switched off, given custom text and placed at one of six positions;
it is only drawn where that edge is free.

Layout reworked for all three widths: work left, sticky preview right on the
desktop, collapsed setting groups paired in a two-column grid that expands full
width; preview first and a fixed action bar on the phone. Verified at 390, 820,
1440 and 1680 px -- no horizontal overflow, no dead white space.

Also a public presentation page at /vorstellung (no login, nothing operable),
whose sample sheet is computed by the real packer rather than drawn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
2026-08-20 10:55:11 +00:00
till aeea706eb6 fix: keep playwright out of package.json
It slipped in from a local UI check. The Docker build installs devDependencies,
and playwright's postinstall would try to download browsers there - the same
class of failure as the tsx/esbuild one.
2026-08-19 07:50:59 +00:00
till 210a84dcf8 feat: rename to Drucken, 86 image sizes, fix the distorted crop editor
The crop frame had width:100% together with max-height, so the height was
clamped while the width stayed - the frame lost the target aspect ratio and
object-fit:fill stretched the picture into it. Measured at 390px: frame 0.843
instead of 0.778, picture 8.3% too wide. The width now follows from the allowed
height and the ratio, and the image carries its own aspect-ratio instead of a
second percentage, so it cannot stretch at all.

Dragging was fine but felt dead: at fill zoom the crop sits at the stop in one
axis. The editor now states which way it can move, double-tap zooms, arrow keys
nudge, and the zoom slider shows its factor.

Sizes: 41 -> 86 in ten groups, including US inch sizes, instant-film picture
areas, cards, DIN A0-A7 and eleven aspect ratios as physical sizes.

The tab is called Drucken now; /druck and /passbilder redirect there.
2026-08-19 07:50:44 +00:00
till 7289cde03a docs: changelog for the code-review fixes 2026-08-18 07:50:30 +00:00
till e87c61435c fix: code-review findings in the print module
Security and robustness:
- EXIF orientation is now applied before any geometry. Phone photos carry the
  rotation only as metadata; sharp was cropping the unrotated raster, so a
  portrait shot came out of the printer sideways and wrongly framed.
- renderCell no longer materialises the padded image at source resolution.
  It is one extract-resize-extend chain now, which is also sharp's internal
  order. A panorama into a narrow contain target used to build a ~960 MB
  intermediate and then fail; it is 90 ms and a few MB now.
- Target size is capped (300 Mpx) and bleedMm is clamped in /api/print/single,
  which had no bound at all.
- The delivery gallery is validated before use - posixpath.join let a crafted
  name escape the target's base folder and create directories there.
- Sheet requests are capped at 500 pieces and the packer has a step budget, so
  a degenerate request cannot block the single-threaded server.
- Print presets: delete only your own (admins all), config size limit, count
  limit, and by_name honours anonymous_generations.
- Telegram callbacks require an active pairing, like every other path.
- Error responses no longer leak storage paths or delivery hostnames.

Correctness:
- allowRotate:undefined now means allowed, consistently with the packer.
- The many-formats shortcut no longer drops a format that only fits rotated.
- unplaced names the format that is actually missing, not the first one.
- Crop marks never sit inside the printed bleed - the offset is raised.
- capacity() computes the grid instead of probing with 200 copies.
- Image keys in the sheet cannot collide with a cell literally named x::rot.
- labelMm keeps real decimals; parseSizeMm reads a:b as width:height, so
  3:4/15 is portrait and 4:3/15 is landscape.
- The footer is skipped when there is no free space at the bottom.
- The UI warns when corner marks do not fit the margin, and when continuous
  guides are used with mixed sizes.

Tests: 21 -> 31, each finding has a regression test.
2026-08-18 07:50:11 +00:00
till 7d42782c41 feat: all AI formats printable, crop-vs-border rule, mobile layout, Passbilder rename
Print now offers every format the AI pipeline knows (9x13 to 60x90, DIN A6-A2,
squares, poster/frame sizes to 70x100, and the screen ratios as physical sizes).
When a picture does not match the target ratio the user picks per image between
cropping to fill and keeping the whole picture on a border colour - never a
stretch. The module is called Passbilder now; /druck redirects.

Fixes two real defects: sharp runs extend after resize, so padded cells came out
oversized (a 35x45 mm cell became 35x171 mm with a border), and the grid packer
rotated a single portrait photo just because more would fit sideways.

Mobile: cards become rows, touch targets ~40px, crop editor as a bottom sheet.
Verified at 390/820/1440px - all three produce the same PDF.
2026-08-18 07:04:08 +00:00
till f6b81a0f5e fix: drop tsx dev dependency, run tests with node's native type stripping
The tsx postinstall pulls its own esbuild binary, which fails inside the
Docker build ("Expected 0.28.2 but got 0.27.7") and broke the deploy.
Tests now run on node --experimental-strip-types, so the image installs
nothing extra.
2026-08-18 06:45:28 +00:00
till 606a78bef0 feat: print module — exact sizes, print sheets and crop marks without AI 2026-08-18 06:40:55 +00:00
till f0442a617c docs: seeded templates and sheet delivery in llms.txt 2026-08-18 06:40:51 +00:00
till 4fb34cdab8 feat: sheet templates, margin profiles and delivering the sheet
Seeds the four sheet setups Till actually prints (Kita set, small school set,
full passport sheet, two 10x7,5 on 10x15 paper). A multi-format template
applied to a single image clones it into every format, so one click yields the
whole set. Margin gets one-click borderless / 5 mm / 10 mm profiles, and the
finished PDF can be pushed to Picdrop, the NAS or any extra target.
2026-08-18 06:40:31 +00:00
till 21a0a68972 docs: print module in help page, llms.txt, README and changelog 2026-08-18 06:20:46 +00:00
till 32f3b91779 feat: print sheets via Telegram and MCP
Telegram gains a fully button-driven print flow (photo -> size -> count ->
A4 PDF with corner marks), running inline without the queue or a model. MCP
gains exact_size and print_sheet so an assistant can produce print-ready files
from a local folder. Keeps the rule that every feature works on all three ways in.
2026-08-18 06:20:46 +00:00
till cd9081823d feat: print page with crop editor and live sheet preview
New Druck tab: upload or pick from the library, choose a target size and copy
count per image, pan/zoom crop on a locked aspect ratio, and a live preview
that renders the same layout the PDF will contain. Sheet controls for paper,
margin, gap, bleed, crop marks, dpi and templates.
2026-08-18 06:20:46 +00:00
till e8b3fde652 feat: print API - exact single image, sheet PDF, sheet presets
POST /api/print/single (image at an exact size, dpi headers incl. a real-dpi
warning), GET /api/print/single as a mm-to-px calculator, POST /api/print/sheet
(multi-image sheet as PDF, limits on cells/copies/pages) and print_presets for
reusable sheet setups.
2026-08-18 06:20:46 +00:00
till a593dea6c5 feat: exact-size rendering and 1:1 sheet PDF (sharp + pdf-lib)
renderCell brings a relative crop onto an exact physical size; missing bleed
pixels are copied from the edge instead of stretching the motif. Without a
crop it falls back to a centred cover crop, never a distorted fill.
buildSheetPdf writes a PDF page that is exactly the sheet size in mm with
hairline crop marks and an optional footer. Source resolution guards uploads
to the sources/ prefix and reuses the library visibility rules.
2026-08-18 06:20:46 +00:00
till a651025422 feat: mm-based print layout math and paper/photo size tables
Pure geometry, no I/O: DIN A6-A2 incl. A3+ (329x483), photo-paper cuts,
US Letter/Legal, biometric and small photo formats, plus a forgiving free
size parser (12x15 cm, 35x45mm, 5, 4:3/15). Packing: exact grid for uniform
sizes, MaxRects with one fixed orientation per format for mixed sets, so
small prints fill the space next to a large one. Crop marks as line segments
(corner marks outside the trim box; continuous guides that never cross a
motif). Covered by tests/printlayout.test.ts.
2026-08-18 06:20:45 +00:00
till ed22a76fda feat: deliver in chosen format (not forced JPEG); wire output_ext through jobs/Telegram/MCP
- delivery.ts: upload the stored result in its chosen format (output_ext,
  global or per-recipe) instead of always converting non-alpha to JPEG.
- jobs POST froze the snapshot without output_ext -> per-conversion/per-recipe
  format choice was silently dropped. Now carried through.
- telegram.ts: recipe snapshots carry output_ext + delivery_target_id
  (custom formats already flow via output_format).
- mcp/klarbild-mcp.mjs: process_images gains tasks, output_ext, orientation,
  crop_mode, contour_mm, picdrop_gallery; job_status shows finished_at + errors.
- Docs: llms.txt, mcp/README, changelog updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-24 09:12:07 +00:00
till ae91f4bfd5 feat: llms.txt (AI-readable docs), job delete, friendly missing-source error
- /llms.txt: public machine-readable capability/API/MCP doc for agents.
- Job delete action + queue button for terminal jobs; cleans objects.
- process.ts: clear 'Quelle nicht mehr vorhanden' message instead of raw
  EISDIR/ENOENT when a source was purged.
- Docs corrected: Picdrop 'missing images' was web-UI sorting, not PNG;
  JPG delivery kept as size/perf improvement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-24 08:57:31 +00:00
till d54d322d6a docs: changelog + README handover for Picdrop/JPG/formats/failed-jobs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-24 08:41:06 +00:00
till 3a061aa612 feat: custom formats (WxH/stickerN), JPG output option, failed-job status
- format.ts parses free formats from the key: sticker<N> -> N×N cm,
  <W>x<H> -> W×H cm. Fixes 'Unbekanntes Format: sticker5' (the seeded
  'Sticker 5 cm' recipe was unusable) and lets admins define ANY format
  without a migration. Guarded to <=300 cm.
- Output file format PNG/JPG: global default (settings.output_ext) plus
  per-recipe/per-conversion override. JPG only for non-alpha; cutouts
  stay PNG. file.ts sniffs content-type from magic bytes.
- Jobs can now be 'failed' (all items failed) -> red in the queue; add
  finished time to queue list + detail. migration 012.
- Studio: custom-measure input + Dateiformat selector; Admin: global
  Standard-Dateiformat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-24 08:32:03 +00:00
till efcb889c96 fix: strip dates/UUIDs in filenames even when adjacent to underscores
Word-boundary (\b) anchors don't fire next to underscores, so
already-processed names like 2026-07-24_2026-07-24-<uuid>_30x40 kept
their doubled date prefix. Drop \b; match dates/hex globally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-24 08:13:36 +00:00
till 70f3904d0e fix: deliver JPEG (not huge PNG) to Picdrop, clean filenames, tmp cleanup
- Picdrop is a photo-proofing tool that expects handy JPGs; non-alpha
  results are now delivered as JPEG q92 (4:4:4) instead of 12-32 MB PNGs.
  Cutouts with alpha stay PNG. Fixes images not showing in POSTER LEA.
- buildResultFilename strips doubled dates/UUIDs/hex to avoid messy names.
- picdrop-ls gains action:cleanup_tmp; Admin 'Reste aufräumen' button
  removes leftover .tmp- files from the default gallery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-24 08:07:16 +00:00
till 32f06297a6 feat: per-source .md sidecar toggles, Picdrop folder-list diagnostic, fast JPEG fullscreen preview + thumb-first, thumbnail backfill
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-24 07:56:57 +00:00
till 5f0c33e44b docs: changelog for color tags + tap-to-save
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-24 07:43:57 +00:00
till af34cb9cc8 feat: Picdrop-style color tags (Rot/Orange/Gruen/Final) in library + in .md metadata; tap-to-open fullscreen for iPhone Save-to-Photos
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-24 07:38:20 +00:00
till f95a3b58b7 docs: changelog for access control, private sessions, NSFW gate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 21:47:25 +00:00
till e6b325eb53 feat: per-user visibility (own/shared), anonymous generations, private sessions (generate+forget), NSFW model gating
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 21:46:47 +00:00
till 2588a54eb7 docs: changelog + roadmap for transform/backup/metadata/prompt/presets/MCP
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 21:30:45 +00:00
till 295f0ce91e feat: single-image transform, NAS/FTP in delivery, backup-all any target, metadata sidecar, prompt view, preset manager, API token + MCP server
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 21:29:43 +00:00
till 5cfafb456c docs: changelog + roadmap update strategy & multi-target
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 21:06:04 +00:00
till 36f3c8da22 feat: multiple delivery targets (FTP/SFTP), per-preset wiring; The Frame -> TheFrame-Backgrounds gallery
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 21:04:27 +00:00
till 3a098d708c feat: mirror-all-to-NAS, content reset (danger zone), in-app changelog + CHANGELOG.md, How-To refresh
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 20:52:48 +00:00
till 79454c0b92 feat: show NAS backup status badge in library
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 20:43:10 +00:00
till 5fba397efa refactor: NAS mirror uses base folder directly (no redundant klarbild segment)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 20:32:50 +00:00
till 3bfeadbaa0 feat: bulk ZIP download, bulk move-to-folder, folder rename
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 20:23:03 +00:00
till 7608bc62da feat: button-driven Telegram menu (reply keyboard + commands), reachability diagnostic, How-To update
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 20:12:27 +00:00
till 5105c7d529 fix: before/after works for compose (source fallback), hidden for text-only; add reuse result as new Studio input
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 20:01:17 +00:00
till 063903c60a feat: How-To/Anleitung page + nav link
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 19:39:05 +00:00
till 5b654b1436 feat: offer FLUX.2 (open-source) image model
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 19:39:05 +00:00
till 78737936fb feat: functional folders (filter/delete/gallery) + always show model + variant switcher
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 19:39:05 +00:00
till e59d26fadd feat: regenerate/alternatives grouped by source (variant_of)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 19:39:04 +00:00
till 34aa45b292 fix: idempotent job completion + monthly budget cap enforcement
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 19:19:47 +00:00
till a69e2452e1 feat: admin storage + NAS settings card
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 19:19:47 +00:00
till 3e2e09a38d feat: Studio mode selector + Telegram compose/generate (full parity)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 19:19:47 +00:00
till f5cbcdf4d1 feat: storage management (retention, source purge) + Synology NAS backup
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 19:19:40 +00:00
till b705d77240 feat: low-res webp thumbnails for library previews
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 19:19:39 +00:00
till 044eb7f1ae feat: compose + text-to-image generation modes (multi-image + free-text)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-23 19:19:39 +00:00
81 changed files with 7772 additions and 322 deletions
+232
View File
@@ -0,0 +1,232 @@
# Changelog
All notable changes to Klarbild are documented here. Newest first.
## 2026-08-20 — Oversize sheets, duplicate images, free labelling, new layout
### Added
- **Prints larger than the sheet.** The everyday case is 20 × 30 cm on A4 photo paper, where
exactly 3 mm are missing. Instead of refusing, each cell now offers two ways out
(`src/lib/printoversize.ts`, dependency-free like `printlayout.ts` so browser and server compute
the same thing):
- `overflow` centres the piece on the **whole sheet**, not the printable area — otherwise you
throw away the margin a borderless printer can actually use. How much is lost per side is
computed up front and shown before printing.
- `tile` splits the piece across several sheets. Tiles overlap by a glue fold (10 mm by default),
printed on both neighbours and marked with a dashed line. Each sheet carries its number, row
and column. 30 × 40 cm on A4 → 2 × 2 sheets.
Verified by rasterising the PDFs at 254 dpi: the overflow case measures 200,1 mm wide at x = 5 mm
and bleeds off both long edges; the tiles measure 200 × 286 and 110 × 124 mm, overlapping by 10.
- **Duplicate an image.** The same picture can now appear as several cells and be printed in two
different sizes on one sheet. Previously you had to copy the file, rename it and upload it again.
- **Configurable label.** The footer line can be switched off, given custom text, and placed at one
of six positions. It is only drawn if that edge is actually free — it never lands on a picture.
- **Public presentation page** at `/vorstellung` (no login, nothing operable). The sample sheet on it
is not a drawing: it runs the real packer at render time, so it follows the algorithm.
### Fixed
- **The tile number overlapped the picture.** The sheet number is printed at 8 pt from 3 mm off the
top edge, which needs about 6 mm — with a 5 mm paper margin it ran 0,8 mm into the motif.
`noteInset()`/`tileSheet()` now shift the tile down by the shortfall and shorten the sheet by the
same amount, so nothing sticks out at the bottom. Both the browser preview and the PDF call the
same helper, so the previewed sheet count cannot drift from the printed one.
### Changed
- **Desktop layout.** Work on the left, preview on the right, and the preview column is sticky so it
follows along — no more endless column next to an empty one. Setting groups sit collapsed side by
side in a two-column grid and expand across the full width, so three-way switches keep their
labels instead of being squeezed to 50 px.
- **Mobile layout.** Preview first, "Druck-PDF erzeugen" as a fixed bar above the tab navigation,
each image row showing thumbnail and controls side by side, touch targets at 3840 px, and the
remove button moved onto the thumbnail so it stops covering the format dropdown. The step numbers
("1 ·", "2 ·") are hidden below 1080 px, where the preview comes first and the numbers would lie.
- Advanced setting groups (margins, quality, labelling, delivery) start collapsed — their summary
line already says what is set.
## 2026-08-18 (4) — Renamed to "Drucken", 86 sizes, crop editor fixed
### Fixed
- **The crop editor distorted the picture.** `.crop-box` had `width:100%` *and*
`max-height`, so the height was clamped while the width stayed put — the frame no
longer had the target aspect ratio, and `object-fit:fill` stretched the image into it.
Measured on a 390 px viewport: frame 0.843 instead of 0.778, picture **8.3 % too wide**.
The frame width is now derived from the allowed height and the ratio, and the image
carries its own `aspect-ratio` instead of a second percentage — it can no longer stretch,
even if the frame is ever off.
- **Dragging felt broken.** At "fill" zoom the crop sits exactly at the stop in one axis,
so pulling that way does nothing — correct, but invisible. The editor now says which way
the picture can move, double-click/double-tap zooms a step, arrow keys nudge (shift for
bigger steps), and the zoom slider shows its factor.
### Changed
- **Renamed to "Drucken"** — the tab, page, title and Telegram button. `/druck` and
`/passbilder` both redirect permanently to `/drucken`.
- **86 image sizes in ten groups** (was 41): passport formats per country, cm small sizes,
classic photo sizes, **US inch sizes** (wallet to 24 × 36), **instant-film picture areas**
(Instax mini/Square/Wide, Polaroid), poster and frame sizes up to 100 × 140, squares,
**DIN A0A7 incl. A3+**, cards (business card, postcard, DIN lang, cheque card) and
aspect ratios as physical sizes (16:9, 9:16, 21:9, 2:1, 3:2, 2:3, 4:3, 3:4, 5:4, 4:5, 1:1).
- Paper list grew to 19: DIN A1, square photo paper, and the US inch cuts.
## 2026-08-18 (3) — Code review: fixes
### Fixed
- **EXIF orientation was ignored.** Phone photos carry their rotation as metadata only.
sharp cropped the unrotated raster, so a portrait shot came out of the printer sideways
and with the wrong framing. Orientation is now applied before any geometry, and
`/api/uploads` reports oriented dimensions so the crop editor agrees with the render.
- **Memory blow-up on "Rand lassen".** The padded image was materialised at source
resolution before the resize. A panorama into a narrow contain target built a ~960 MB
intermediate and then failed outright; it is one extract→resize→extend chain now
(sharp's own order) — 90 ms and a few MB.
- **`bleedMm` was unbounded** in `/api/print/single` (the sheet endpoint clamped it).
- **Delivery gallery could escape the target's base folder** — `posixpath.join` happily
resolves `../..`, and the folder is created before upload. Names are validated now.
- **Denial of service:** sheet requests are capped at 500 pieces and the packer has a
step budget, so a degenerate request can no longer block the single-threaded server.
- **Print presets:** delete only your own (admins all), config size and count limits,
and `by_name` honours `anonymous_generations`.
- **Telegram callbacks** now require an active pairing, like every other path.
- **Error responses** no longer leak storage paths or delivery hostnames.
- `allowRotate: undefined` meant "no rotation" in one place and "rotation allowed" in
two others — a picture that only fits rotated was reported as unplaceable.
- The many-formats shortcut dropped a format that only fits rotated.
- `unplaced` blamed the first format instead of the one actually missing.
- Corner marks could land inside the printed bleed; the offset is raised to clear it.
- `capacity()` silently capped at 200.
- Image keys could collide with a cell literally named `x::rot`.
- `labelMm` rounded away real decimals (11,25 cm became 11,3); `parseSizeMm` ignored the
order in `a:b`, so `3:4/15` and `4:3/15` produced the same portrait size.
- The footer was drawn over the artwork when the margin was small.
- The UI now warns when corner marks do not fit the margin, and when continuous guides
are combined with mixed sizes (they cannot run through).
### Changed
- Tests grew from 21 to 31 — every finding above has a regression test.
## 2026-08-18 (2) — Passbildfunktion: all sizes, fit rules, mobile
### Added
- **Renamed to "Passbilder"** — the tab, page and Telegram button now say what it is.
`/druck` permanently redirects to `/passbilder`.
- **Every size the AI pipeline knows** is now printable too: 9×13 … 60×90, DIN A6A2,
squares, plus poster/frame sizes up to 70×100 and the screen ratios (16:9 "The Frame",
9:16, 3:2, 4:3, 5:4) as physical measurements. 41 presets in seven groups.
- **Fit rule per image** — when the picture does not match the target ratio, choose
**Zuschneiden** (fill the format, crop the overflow — default) or **Rand lassen**
(keep the whole picture, pad with a border colour: white, black, paper or custom).
Nothing is ever distorted. Works in the crop editor, the sheet preview, the PDF,
`/api/print/*` (`fit`, `bg`) and the saved templates.
- **Mobile layout** — image cards become a row on phones, touch targets grow to ~40 px,
the crop editor turns into a full-width bottom sheet, no horizontal overflow.
Verified at 390 / 820 / 1440 px; all three produce the identical PDF.
### Fixed
- **Sheet cells could come out oversized.** sharp applies `extend` *after* `resize`, so
padding was added on top of the finished size — a 35×45 mm cell became 35×171 mm in
"Rand lassen" mode, and bleed near an image edge was off too. Crop and padding now run
in their own pass. Covered by `tests/printrender.test.ts`.
- **Orientation is no longer overridden.** A single portrait passport photo was laid down
sideways just because more would fit that way. Rotation now only happens when it
actually saves a sheet.
## 2026-08-18 — Print module: exact sizes, sheets & crop marks (no AI)
### Added
- **Print module (`/druck`)** — a lab-style, completely AI-free path: crop, scale and place
images at exact physical sizes. No model call, no cost, no queue.
- **Interactive crop editor** — pan and zoom on a fixed target aspect ratio (rule-of-thirds
overlay); the crop is stored relative (0..1) so it survives any source resolution.
- **Size presets & free input** — biometric passport 35 × 45 mm, 2 × 3 / 3 × 4 / 4 × 5 / 4,5 × 6 cm,
9 × 13 … 50 × 70 cm, squares, DIN. Free input understands `12x15` (cm), `35x45mm`, `5`
(= 5 × 5 cm) and `4:3/15` (ratio + long edge).
- **Sheet layout** — paper presets DIN A6A2 incl. **A3+ (329 × 483 mm)**, photo-paper cuts
(9 × 13 … 20 × 30), US Letter/Legal, plus any custom paper size. Portrait/landscape,
printer margin, centering, per-image copy count.
- **Automatic nesting** — identical sizes produce an exact grid; mixed sizes go through a
MaxRects packer that fixes one orientation per format and tries every combination,
so eight passport photos land in the free space next to the 13 × 18.
- **Crop marks** — `corner` (fine 0.25 pt marks outside the trim box, Photoshop/InDesign
convention, 4 mm long / 3 mm offset by default) or `grid` (continuous guides across the
sheet that never cross another image). Gap between images adapts to the chosen marks.
- **Bleed** (010 mm) — the image extends past the trim box, edge pixels are copied rather
than stretched; the gap is always ≥ 2 × bleed so neighbouring bleeds cannot overlap.
- **PDF output at 1:1** — the PDF page is exactly the sheet size (pdf-lib), with a footer
stating the sizes, dpi and the "print at 100 %, not fit-to-page" reminder.
- **Single export** — one image at an exact size as PNG/JPG with dpi metadata, plus a
resolution warning when the source cannot hold the requested dpi.
- **Sheet templates** — save the whole sheet setup (paper, marks, formats, counts) as a
reusable preset (`print_presets`, migration `013`).
- **All three entrances** — `/druck` in the web app, `📐 Druckbogen` in Telegram (fully
button-driven, returns an A4 PDF), and the MCP tools `exact_size` / `print_sheet`.
- **API** — `POST /api/print/single`, `GET /api/print/single?size=…` (mm → px calculator),
`POST /api/print/sheet`, `GET|POST|DELETE /api/print/presets`. Documented in `/llms.txt`.
- **Seeded sheet templates** — "Kita-Satz (A4)", "Schulsatz klein (A4)", "Passbildbogen 35×45 (A4)"
and "2 × 10×7,5 auf Fotopapier 10×15". Loading a multi-format template with a single image
clones that image into every format, so one click produces the whole set.
- **Margin profiles** — borderless (0 mm), standard (5 mm) and safe (10 mm) as one-click buttons.
- **Deliver the sheet** — the finished PDF can additionally be pushed to Picdrop, the NAS or any
extra delivery target (`deliver` in the sheet request).
- **Tests** — `tests/printlayout.test.ts` (`npm test`) covers size parsing, exact mm → px,
non-overlap, margins, bleed spacing, mark geometry and multi-sheet paging.
## 2026-07-23 — Compose, generate & second backup
### Added
- **Compose mode** — combine multiple images + a text description into one new image.
- **Generate mode** — create images from text only, no source needed.
- **The Frame "extend"** — outpaint more scene around the motif instead of only cropping.
- **Reuse** — take a finished result back into the studio as a new source to edit further.
- **Alternatives** — generate additional versions of the same image, grouped with a switcher.
- **Folders** — filter, rename and delete in the library; per-folder delivery gallery mapping.
- **Bulk actions** — download selected results as a ZIP, move several into a folder at once.
- **Thumbnails** — low-res previews for the library grid.
- **Models** — quality tiers incl. open-source (FLUX.2); the used model is shown per image.
- **Color tags** — Picdrop-style flags (red / orange / green / final) per image, filterable, and
written into the companion `.md` metadata.
- **Tap-to-save** — "Laden" opens the image full-bleed so on iPhone/iPad a long-press offers
"Save to Photos" (plus a classic file download).
- **Access control & privacy** — library visibility (own-only vs shared), optional anonymous
generations (creator hidden from non-admins), admins always see all.
- **Private sessions** — generate-and-forget: no library entry, no delivery/backup, no stored
prompt, sources purged, result auto-deleted shortly after. Enabled globally by admin or forced
per user.
- **NSFW gate** — models flagged NSFW are only selectable/visible when the admin enables it.
Operator responsibility; content involving minors is never permitted.
- **Single-image transform** — compose now accepts one image, so a single photo can be
transformed by prompt (e.g. "make it an oil painting") and put onto a format.
- **Multiple delivery targets** — besides the default Picdrop, add any number of FTP/SFTP
destinations; selectable in the studio ("Wohin?", incl. NAS); a preset or folder can be
hard-wired to a specific target + gallery.
- **Backup to any target** — mark any target as a backup destination (like the NAS); one-click
"back up all images" mirrors to all backup destinations.
- **Metadata sidecar** — optionally ship a companion `.md` (prompt, model, format …) next to
each image on Picdrop/NAS/FTP.
- **Prompt view** — every generated image shows its prompt in the library (with copy).
- **Preset manager** — save current settings as a preset, load and delete presets.
- **API token + MCP server** — programmatic access so an assistant can push images from a
local folder or iMessage into Klarbild (`mcp/klarbild-mcp.mjs`).
- **The Frame** preset now delivers to its own gallery ("TheFrame-Backgrounds").
- **Second backup** — mirror every result over SFTP/FTPS to a NAS (works over Tailscale too),
with a "NAS ✓" badge and a "mirror all existing images" action.
- **Storage management** — thumbnails, delete sources after processing, auto-retention.
- **Telegram** — fully button-driven (persistent keyboard + command menu): edit, compose,
"new image", reuse — no slash commands required.
- **Reset** — wipe library, jobs and folders cleanly (keeps users, settings, models, recipes),
guarded by a typed confirmation.
- **Help page**, in-app **changelog**, and a NAS **reachability diagnostic**.
### Fixed / hardened
- Real per-image cost cap (monthly budget) enforced by the worker.
- Idempotent job completion (no duplicate delivery/notification).
- Before/after comparison works for composed images; hidden for text-only results.
## 2026-07-22 — Foundation
### Added
- Astro 5 + React islands UI (mobile-first), PostgreSQL, fs/S3 storage abstraction.
- pg-boss job queue with in-process worker; sharp pipeline (exact cm/dpi print sizes,
sticker contour).
- OpenRouter image gateway; admin-managed models.
- Picdrop delivery over SFTP/FTPS; recipes; library with before/after.
- Telegram bot (grammY webhook); argon2 auth; AES-256-GCM secret encryption.
+65 -2
View File
@@ -8,7 +8,8 @@ für den Cricut freistellen, und automatisch nach Picdrop ausliefern.
## Stack ## Stack
Astro 5 (SSR, Node standalone) + React-Inseln · Postgres · S3 (MinIO) · pg-boss · Astro 5 (SSR, Node standalone) + React-Inseln · Postgres · S3 (MinIO) · pg-boss ·
sharp · grammY (Telegram) · OKLCH-Tokens, kein Tailwind. Deploy: Gitea → Coolify → Hetzner. sharp · pdf-lib (Druckbogen) · grammY (Telegram) · OKLCH-Tokens, kein Tailwind.
Deploy: Gitea → Coolify → Hetzner.
## Entwicklung ## Entwicklung
```bash ```bash
@@ -19,6 +20,68 @@ npm run dev
Migrationen und Seeds laufen beim Serverstart automatisch. Erststart-Passwörter über Migrationen und Seeds laufen beim Serverstart automatisch. Erststart-Passwörter über
`SEED_TILL_PASSWORD` / `SEED_LEA_PASSWORD`. `SEED_TILL_PASSWORD` / `SEED_LEA_PASSWORD`.
## Funktionen (Kurzüberblick)
- **Modi:** Bereinigen (Screenshot säubern/freistellen/formatieren), Umwandeln (ein Bild + Text,
z. B. Foto → Ölgemälde), Kombinieren (mehrere Bilder + Text), Neu erzeugen (nur Text).
- **Formate:** feste cm-/DIN-/Screen-Formate **und frei definierbare** — Schlüssel `WxH` (z. B.
`25x35`) oder `sticker<N>` (`sticker5` = 5×5 cm). Parsing in `src/lib/format.ts`, keine Migration nötig.
- **Dateiformat:** PNG (verlustfrei, Transparenz) oder JPG (klein, Picdrop-freundlich). Global über
`settings.output_ext`, pro Rezept via `recipes.output_ext`, pro Umwandlung im Studio. JPG nur ohne Alpha.
- **Auslieferung:** Picdrop (SFTP/FTPS) + beliebige Zusatzziele; NAS/Backup-Ziele; optionaler
`.md`-Metadaten-Beileger **pro Ziel** schaltbar. Nicht-transparente Ergebnisse gehen als JPG an Picdrop.
- **Bibliothek:** Ordner, Farbmarkierungen, Prompt-Anzeige, Vorher/Nachher, Vollbild + „In Fotos sichern".
- **Rechte/Datenschutz:** Sichtbarkeit (eigene/alle), anonyme Generierungen, private Sessions, NSFW-Gate.
- **Telegram-Bot** (grammY-Webhook) als vollwertiger Website-Ersatz. **API-Token** (`Bearer`) für `/api/*`.
- **Drucken (`/drucken`, ohne KI):** Bilder exakt auf physische Maße bringen (interaktiver Zuschnitt mit
Zoom/Verschieben) und mehrere davon in 100-%-Größe mit **Schnittmarken** auf einen Bogen setzen —
Passbildsatz, Kita-Satz, Sticker. Papier DIN A6A2 inkl. **A3+**, Fotopapier 9×1320×30, Letter/Legal
oder freies Maß. Marken: Eckmarken (0,25 pt, außerhalb des Endformats) oder durchgehende Linien.
Beschnittzugabe 010 mm. Ausgabe: PDF in 1:1 (`pdf-lib`) bzw. Einzelbild mit dpi-Metadaten.
Layout: gleiche Größen → exaktes Raster, gemischte Größen → MaxRects-Packer.
Alle Formate der KI-Generierung sind auch hier wählbar; passt das Bild nicht zum Format,
entscheidet man je Bild zwischen **Zuschneiden** und **Rand lassen** (mit Randfarbe) — nie verzerren.
86 Bildmaße in 10 Gruppen (Passbild bis 100 × 140 cm), 19 Papierformate.
**Größer als der Bogen** (20 × 30 cm auf A4): je Zelle `oversize` = `fit` (melden) ·
`overflow` (mittig aufs ganze Blatt, Verlust wird vorab beziffert — hier genau 3 mm) ·
`tile` (Posterdruck auf mehrere Blätter mit gestricheltem Klebefalz und Blattnummer).
**Beschriftung** frei: aus, automatisch oder eigener Text an einer von sechs Stellen;
sie entfällt automatisch, wenn an der Kante kein Platz frei ist.
Dasselbe Bild darf mehrfach als Zelle vorkommen („Duplizieren"), um es in zwei Maßen zu drucken.
Kern: `src/lib/paper.ts` (Formate), `src/lib/printlayout.ts` (reine Mathematik, getestet),
`src/lib/printoversize.ts` (Überstand & Kachelung, getestet), `src/lib/printrender.ts`
(sharp + pdf-lib). Browser-Vorschau und Server-PDF benutzen denselben Rechenkern.
Auch per Telegram („🖨 Drucken") und MCP (`exact_size`, `print_sheet`).
Öffentliche Vorstellungsseite ohne Login: `/vorstellung`.
- **Admin:** Key/Picdrop/Modelle/Nutzer/Kostendeckel/Presets/Speicherverwaltung, Picdrop-Diagnose.
## Datenmodell / Migrationen
Nummerierte SQL in `migrations/`, beim Start automatisch (kein ORM-Push). Wichtige Spalten:
`items.has_alpha`, `jobs.status` (inkl. `failed`), `jobs.finished_at`,
`settings.output_ext` / `recipes.output_ext`, `settings.*_metadata_sidecar`, `delivery_targets.*`,
`print_presets` (Bogen-Vorlagen, `013`).
## Tests
`npm test` — Node-Testrunner mit nativem Type-Stripping (`node --experimental-strip-types`).
**Kein `tsx`, kein `playwright` in `package.json`** — der Docker-Build installiert devDependencies,
und deren native postinstall-Schritte haben den Deploy schon zweimal zerlegt. Testwerkzeug gehört
außerhalb des Projekts installiert. Deckt die Druck-Layoutmathematik ab (Maß-Parsing,
mm → px exakt, Überlappungsfreiheit, Ränder, Beschnittzugabe, Markengeometrie, Mehrseitigkeit).
## Stolpersteine & Lösungen (Learnings)
- **„Bild bei Picdrop ✓, aber nicht in der Galerie":** Fehldiagnose vermeiden — die Dateien lagen die ganze
Zeit **korrekt** per SFTP in `/POSTER LEA` (per Admin → Picdrop → „Ordner anzeigen" verifizierbar). Der
eigentliche Grund war die **Sortierung in der Picdrop-Weboberfläche**, nicht das Format. Unabhängig davon
liefern wir nicht-transparente Ergebnisse jetzt als JPG aus (`src/lib/delivery.ts`, `deliverableBuffer`):
~25 MB PNG → ~2 MB JPG, schneller und handlicher — plus wählbares Ausgabeformat (`output_ext`).
- **„Unbekanntes Format: sticker5":** Seed legte ein Rezept mit Formatschlüssel `sticker5` an, den der
Resolver nicht kannte → jeder Lauf schlug fehl. Lösung: generisches Parsen von `sticker<N>`/`WxH`.
- **Auftrag trotz Fehler grün:** `jobs.status` kannte kein `failed`; alle Positionen fehlgeschlagen wurde
trotzdem `done`. Lösung: Status `failed`, wenn `done=0 && failed>0` (`src/worker.ts` `maybeComplete`).
- **Doppelte Datums-/UUID-Namen:** `\b`-Wortgrenzen greifen nicht an Unterstrichen. Lösung: `\b` entfernt,
Datum/UUID/Hex global ersetzt (`src/lib/pipeline.ts` `cleanMotif`).
- **Content-Type bei gemischten PNG/JPG-Ergebnissen:** `file.ts` snifft jetzt die Magic Bytes.
- **`.tmp-`-Reste auf Picdrop:** früher Temp-Rename-Bug; `cleanupTmp` + Admin-Knopf „Reste aufräumen".
## Status ## Status
Im Aufbau. Reihenfolge und Nachweise siehe `CLAUDE.md`. Anforderungen im Handover-Paket Reihenfolge und Nachweise siehe `CLAUDE.md`. Anforderungen im Handover-Paket
(`Imagetool-Lea/00..07`). Führend: `01-anforderungen.md`. (`Imagetool-Lea/00..07`). Führend: `01-anforderungen.md`.
+44
View File
@@ -0,0 +1,44 @@
# Klarbild MCP-Server
Erlaubt einem KI-Assistenten (z. B. Claude), Bilder an Klarbild zu übergeben und dort zu
verarbeiten — etwa „nimm die Bilder aus Ordner XY" oder „verarbeite die Fotos, die mir jemand
per iMessage geschickt hat". Der Assistent besorgt die Bilddateien (mit seinen eigenen
Werkzeugen) und ruft dann die Klarbild-Tools auf.
## Einrichtung
1. In Klarbild einen Token erzeugen: **Admin → „Automatisierung / MCP-Zugriff" → Token erzeugen**.
2. Abhängigkeit installieren: `npm i @modelcontextprotocol/sdk`
3. Server starten (bzw. im MCP-Client eintragen):
```json
{
"mcpServers": {
"klarbild": {
"command": "node",
"args": ["/pfad/zu/mcp/klarbild-mcp.mjs"],
"env": {
"KLARBILD_URL": "https://klarbild.heidrich-digital.de",
"KLARBILD_TOKEN": "klb_…"
}
}
}
}
```
## Tools
- **list_recipes** — verfügbare Presets/Rezepte auflisten.
- **process_images** — Bilder (lokale Pfade oder `data:`-URLs) hochladen und verarbeiten.
Entweder `recipeId` (Preset) oder `mode` (`each`/`compose`/`generate`) + Optionen:
`tasks` (`clean`/`cutout`/`format`/`contour`), `prompt_text`, `output_format`
(fest wie `30x40`/`theframe` **oder frei** `25x35` / `sticker5`), `output_ext` (`png`/`jpg`,
weglassen = globaler Standard), `orientation`, `crop_mode`, `contour_mm`, `picdrop_gallery`,
`delivery`. Liefert die Auftrags-ID.
- **job_status** — Status eines Auftrags abfragen (inkl. Endzeit und Fehlermeldungen).
## Sicherheit
Der Token gibt vollen programmatischen Zugriff auf Upload/Verarbeitung (nicht auf den
Admin-Bereich). Wie ein Passwort behandeln; bei Verdacht neuen Token erzeugen (der alte wird
dadurch ungültig).
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
// Klarbild MCP-Server — erlaubt einem KI-Assistenten (z. B. Claude), lokale Bilder
// oder anderweitig beschaffte Bilder (iMessage, Ordner XY …) an Klarbild zu übergeben
// und dort zu verarbeiten. Der Assistent besorgt die Bilddateien; dieser Server lädt
// sie hoch und legt einen Auftrag an.
//
// Setup:
// npm i @modelcontextprotocol/sdk
// KLARBILD_URL=https://klarbild.heidrich-digital.de \
// KLARBILD_TOKEN=klb_xxx node mcp/klarbild-mcp.mjs
// Den Token erzeugt man in Klarbild unter Admin → „Automatisierung / MCP-Zugriff".
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, resolve } from 'node:path';
const BASE = (process.env.KLARBILD_URL || '').replace(/\/$/, '');
const TOKEN = process.env.KLARBILD_TOKEN || '';
if (!BASE || !TOKEN) { console.error('KLARBILD_URL und KLARBILD_TOKEN müssen gesetzt sein.'); process.exit(1); }
const authHeaders = { Authorization: `Bearer ${TOKEN}` };
const api = (path) => `${BASE}${path}`;
async function uploadFile(pathOrBase64, name) {
let buf, filename = name;
if (pathOrBase64.startsWith('data:')) {
buf = Buffer.from(pathOrBase64.split(',')[1], 'base64');
filename = name || 'bild.png';
} else {
buf = await readFile(pathOrBase64);
filename = name || basename(pathOrBase64);
}
const fd = new FormData();
fd.append('files', new Blob([buf]), filename);
const r = await fetch(api('/api/uploads'), { method: 'POST', headers: authHeaders, body: fd });
const j = await r.json();
const info = (j.files || [])[0];
if (!info?.source_path) throw new Error(info?.error || 'Upload fehlgeschlagen');
return { source_path: info.source_path, filename };
}
const server = new Server({ name: 'klarbild', version: '1.0.0' }, { capabilities: { tools: {} } });
const TOOLS = [
{ name: 'list_recipes', description: 'Verfügbare Presets/Rezepte in Klarbild auflisten.',
inputSchema: { type: 'object', properties: {} } },
{ name: 'process_images',
description: 'Bilder an Klarbild übergeben und verarbeiten. Dateien als lokale Pfade ODER data:-URLs. Entweder recipeId ODER mode+Optionen angeben.',
inputSchema: { type: 'object', properties: {
files: { type: 'array', items: { type: 'string' }, description: 'Lokale Pfade oder data:-URLs' },
recipeId: { type: 'string', description: 'Optional: Preset-ID (aus list_recipes) — überschreibt die Einzeloptionen' },
mode: { type: 'string', enum: ['each', 'compose', 'generate'], description: 'Falls kein recipeId' },
tasks: { type: 'array', items: { type: 'string', enum: ['clean', 'cutout', 'format', 'contour'] },
description: 'Nur bei mode=each. Standard: ["format"] wenn output_format gesetzt.' },
prompt_text: { type: 'string', description: 'Beschreibung für compose/generate' },
output_format: { type: 'string', description: 'Fest: 30x40, A4, theframe, hochformat, keep … ODER frei: "25x35" (=25×35 cm) bzw. "sticker5" (=5×5 cm).' },
output_ext: { type: 'string', enum: ['png', 'jpg'], description: 'Dateiformat. Weglassen = globale Standard-Einstellung. JPG nur ohne Transparenz.' },
orientation: { type: 'string', enum: ['portrait', 'landscape'] },
crop_mode: { type: 'string', enum: ['crop', 'extend'] },
contour_mm: { type: 'number', description: 'Stickerrand in mm (nur mit tasks=cutout+contour).' },
picdrop_gallery: { type: 'string', description: 'Ziel-Galerie/Unterordner (bei delivery picdrop/both).' },
delivery: { type: 'string', enum: ['library', 'picdrop', 'both'] },
}, required: ['files'] } },
{ name: 'job_status', description: 'Status eines Auftrags abfragen (inkl. Endzeit und Fehlermeldungen).',
inputSchema: { type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'] } },
{ name: 'exact_size',
description: 'OHNE KI: ein Bild exakt auf ein physisches Maß bringen (mittiger Ausschnitt im Zielverhältnis, dpi-Metadaten). Ergebnis wird lokal gespeichert.',
inputSchema: { type: 'object', properties: {
file: { type: 'string', description: 'Lokaler Pfad oder data:-URL' },
size: { type: 'string', description: 'Maß: "12x15" (cm), "35x45mm", "5" (=5×5 cm) oder "4:3/15"' },
landscape: { type: 'boolean', description: 'Querformat statt Hochformat' },
dpi: { type: 'number', description: 'Standard 300' },
ext: { type: 'string', enum: ['jpg', 'png'] },
out: { type: 'string', description: 'Zielpfad für die Datei (Standard: ./klarbild-<maß>.<ext>)' },
}, required: ['file', 'size'] } },
{ name: 'print_sheet',
description: 'OHNE KI: mehrere Bilder in 100 %-Größe mit Schnittmarken auf einen Druckbogen setzen und als PDF speichern. Für Passbildsätze, Kita-Bilder, Sticker.',
inputSchema: { type: 'object', properties: {
images: { type: 'array', description: 'Je Eintrag: Datei + Endmaß + Stückzahl',
items: { type: 'object', properties: {
file: { type: 'string', description: 'Lokaler Pfad oder data:-URL' },
size: { type: 'string', description: 'Endmaß, z. B. "35x45mm", "9x13", "12x15"' },
count: { type: 'number', description: 'Wie oft auf den Bogen (Standard 1)' },
landscape: { type: 'boolean' },
allowRotate: { type: 'boolean', description: 'Darf gedreht platziert werden (Standard true)' },
}, required: ['file', 'size'] } },
paper: { type: 'string', description: 'A4 (Standard), A3, A3plus, A5, A6, A2, F10x15, F13x18, F9x13, F15x20, F20x30, Letter — oder ein freies Maß wie "32,9x48,3"' },
landscape: { type: 'boolean', description: 'Bogen quer' },
marks: { type: 'string', enum: ['none', 'corner', 'grid'], description: 'Schnitthilfen (Standard corner)' },
marginMm: { type: 'number', description: 'Rand zum Papier, Standard 5' },
gapMm: { type: 'number', description: 'Abstand zwischen den Bildern, Standard passend zu den Marken' },
bleedMm: { type: 'number', description: 'Beschnittzugabe je Seite, Standard 0' },
dpi: { type: 'number', description: 'Standard 300' },
out: { type: 'string', description: 'Zielpfad des PDFs (Standard: ./klarbild-druckbogen.pdf)' },
}, required: ['images'] } },
];
server.setRequestHandler({ method: 'tools/list' }, async () => ({ tools: TOOLS }));
server.setRequestHandler({ method: 'tools/call' }, async (req) => {
const { name, arguments: a = {} } = req.params;
try {
if (name === 'list_recipes') {
const j = await (await fetch(api('/api/recipes'), { headers: authHeaders })).json();
const list = (j.recipes || []).map((r) => `${r.id}${r.name} (${r.mode || 'each'})`).join('\n');
return { content: [{ type: 'text', text: list || 'Keine Presets.' }] };
}
if (name === 'process_images') {
const files = a.files || [];
const mode = a.mode || 'each';
const sources = (mode === 'generate') ? [] : await Promise.all(files.map((f) => uploadFile(f)));
const wantsFormat = a.output_format && a.output_format !== 'keep';
const tasks = Array.isArray(a.tasks) && a.tasks.length ? a.tasks : (wantsFormat ? ['format'] : []);
const body = a.recipeId
? { recipeId: a.recipeId, mode, sources, prompt_text: a.prompt_text }
: { mode, prompt_text: a.prompt_text, sources,
recipe: {
tasks: mode === 'each' ? tasks : (wantsFormat ? ['format'] : []),
output_format: a.output_format || 'keep',
output_ext: a.output_ext || null,
orientation: a.orientation || 'portrait',
crop_mode: a.crop_mode || 'crop',
contour_mm: a.contour_mm ?? null,
picdrop_gallery: a.picdrop_gallery || null,
delivery: a.delivery || 'library',
},
delivery: a.delivery || 'library' };
const j = await (await fetch(api('/api/jobs'), { method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).json();
if (!j.jobId) throw new Error(j.error || 'Auftrag fehlgeschlagen');
return { content: [{ type: 'text', text: `Auftrag angelegt: ${j.jobId}` }] };
}
if (name === 'exact_size') {
const up = await uploadFile(a.file);
const r = await fetch(api('/api/print/single'), {
method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ src: { kind: 'upload', path: up.source_path }, size: a.size,
landscape: !!a.landscape, dpi: a.dpi || 300, ext: a.ext || 'jpg', name: up.filename }),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
const ext = a.ext === 'png' ? 'png' : 'jpg';
const out = resolve(a.out || `./klarbild-${String(a.size).replace(/[^0-9a-z]+/gi, 'x')}.${ext}`);
await writeFile(out, Buffer.from(await r.arrayBuffer()));
const px = r.headers.get('x-klarbild-px'), real = r.headers.get('x-klarbild-real-dpi');
const warn = r.headers.get('x-klarbild-dpi-ok') === '0' ? ` ⚠️ Quelle reicht nur für ca. ${real} dpi.` : '';
return { content: [{ type: 'text', text: `Gespeichert: ${out} (${px} px).${warn}` }] };
}
if (name === 'print_sheet') {
const imgs = a.images || [];
if (!imgs.length) throw new Error('Keine Bilder angegeben.');
const cells = [];
for (const [i, im] of imgs.entries()) {
const up = await uploadFile(im.file);
cells.push({ id: `c${i}`, src: { kind: 'upload', path: up.source_path }, size: im.size,
count: im.count || 1, landscape: !!im.landscape, allowRotate: im.allowRotate !== false });
}
const paper = a.paper && /[x×]/.test(a.paper) ? { size: a.paper } : { id: a.paper || 'A4' };
const body = { paper, landscape: !!a.landscape, marginMm: a.marginMm ?? 5,
bleedMm: a.bleedMm ?? 0, dpi: a.dpi || 300, ext: 'jpg', footer: true,
marks: { mode: a.marks || 'corner' }, cells };
if (a.gapMm != null) body.gapMm = a.gapMm;
const r = await fetch(api('/api/print/sheet'), {
method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
const out = resolve(a.out || './klarbild-druckbogen.pdf');
await writeFile(out, Buffer.from(await r.arrayBuffer()));
return { content: [{ type: 'text', text:
`Gespeichert: ${out}${r.headers.get('x-klarbild-pages')} Bogen, ${r.headers.get('x-klarbild-per-sheet')} Bilder auf Bogen 1. ` +
'Beim Drucken „Tatsächliche Größe / 100 %" wählen.' }] };
}
if (name === 'job_status') {
const j = await (await fetch(api(`/api/jobs/${a.jobId}`), { headers: authHeaders })).json();
const job = j.job || j;
const errs = (j.items || []).map((it) => it.error_message).filter(Boolean);
const fin = job.finished_at ? ` · beendet ${job.finished_at}` : '';
const errTxt = errs.length ? `\nFehler: ${errs.join('; ')}` : '';
return { content: [{ type: 'text', text: `Status: ${job.status}${job.done_count}/${job.total} fertig${job.failed_count ? `, ${job.failed_count} fehlgeschlagen` : ''}${fin}${errTxt}` }] };
}
return { content: [{ type: 'text', text: `Unbekanntes Tool: ${name}` }], isError: true };
} catch (e) {
return { content: [{ type: 'text', text: `Fehler: ${e?.message || e}` }], isError: true };
}
});
await server.connect(new StdioServerTransport());
console.error('[klarbild-mcp] bereit');
@@ -0,0 +1,35 @@
-- Klarbild — Erzeugungs-Modi, Vorschaubilder, Speicherverwaltung, NAS-Sicherung.
-- (04) Kombinieren aus mehreren Bildern + Freitext-Erzeugung, Thumbnails für die
-- Bibliothek, „nicht den Server vollmüllen" (Retention/Quellenlöschen), zweite
-- Datensicherung auf ein Synology-NAS.
-- Auftrags-Modus: each = jede Vorlage einzeln (bisher), compose = mehrere Bilder + Text
-- zu EINEM neuen Bild, generate = reiner Freitext ohne Vorlage.
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'each'
CHECK (mode IN ('each','compose','generate'));
-- Positionen: mehrere Quellbilder (compose) + verkleinertes Vorschaubild.
ALTER TABLE items ADD COLUMN IF NOT EXISTS source_paths jsonb;
ALTER TABLE items ADD COLUMN IF NOT EXISTS thumb_path text;
-- Rezepte können einen Modus vorbelegen (für Telegram-Standardrezepte).
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS mode text NOT NULL DEFAULT 'each'
CHECK (mode IN ('each','compose','generate'));
-- Speicherverwaltung -------------------------------------------------------
ALTER TABLE settings ADD COLUMN IF NOT EXISTS keep_sources bool NOT NULL DEFAULT true;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS make_thumbnails bool NOT NULL DEFAULT true;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS retention_days int; -- NULL = unbegrenzt
-- Zweite Sicherung auf Synology-NAS (SFTP/FTPS, wie Picdrop) ----------------
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_enabled bool NOT NULL DEFAULT false;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_host text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_protocol text CHECK (nas_protocol IN ('ftps','sftp'));
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_port int;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_user text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_password_enc text;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_base_path text;
-- Spiegel-Status je Position (für Sichtbarkeit/Wiederholung).
ALTER TABLE items ADD COLUMN IF NOT EXISTS nas_status text NOT NULL DEFAULT 'none'
CHECK (nas_status IN ('none','pending','mirrored','failed'));
+5
View File
@@ -0,0 +1,5 @@
-- Telegram: Kombinieren/Erzeugen — Bildunterschrift als Beschreibung + Zwischenstatus.
ALTER TABLE telegram_drafts ADD COLUMN IF NOT EXISTS caption text;
ALTER TABLE telegram_drafts DROP CONSTRAINT IF EXISTS telegram_drafts_status_check;
ALTER TABLE telegram_drafts ADD CONSTRAINT telegram_drafts_status_check
CHECK (status IN ('collecting','awaiting_recipe','awaiting_compose_text','dispatched','discarded'));
+4
View File
@@ -0,0 +1,4 @@
-- Alternativen/Regenerierung: mehrere Ergebnisse zum selben Ursprung gruppieren.
-- variant_of zeigt auf das „Wurzel"-Item (die erste Fassung); Alternativen teilen es.
ALTER TABLE items ADD COLUMN IF NOT EXISTS variant_of uuid REFERENCES items(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS items_variant_idx ON items(variant_of);
+21
View File
@@ -0,0 +1,21 @@
-- Mehrere Auslieferungsziele (FTP/SFTP-Quellen), z. B. eine zweite Picdrop-Galerie
-- oder ein anderer FTP-Server. Rezepte/Presets können ein Ziel fest verdrahten.
CREATE TABLE IF NOT EXISTS delivery_targets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
protocol text CHECK (protocol IN ('ftps','sftp')),
host text,
port int,
username text,
password_enc text,
base_path text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Preset → festes Ziel (NULL = Standard-Picdrop aus den Einstellungen).
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS delivery_target_id uuid
REFERENCES delivery_targets(id) ON DELETE SET NULL;
-- Ordner → festes Ziel (optional).
ALTER TABLE folders ADD COLUMN IF NOT EXISTS delivery_target_id uuid
REFERENCES delivery_targets(id) ON DELETE SET NULL;
@@ -0,0 +1,19 @@
-- Backup für beliebige Ziele, Metadaten-Beileger, API-Token für externen Zugriff (MCP).
-- Jedes Zusatzziel kann als Backup-Ziel markiert werden (erhält ALLE Ergebnisse).
ALTER TABLE delivery_targets ADD COLUMN IF NOT EXISTS is_backup bool NOT NULL DEFAULT false;
-- Metadaten als begleitende .md-Datei mitschicken (Prompt, Dateiname, Modell …).
ALTER TABLE settings ADD COLUMN IF NOT EXISTS metadata_sidecar bool NOT NULL DEFAULT false;
-- API-Token für programmatischen Zugriff (Klarbild-MCP / Automationen).
ALTER TABLE settings ADD COLUMN IF NOT EXISTS api_token text;
-- Backup-Status je Ziel und Position (für „auf mehreren Zielen gesichert").
CREATE TABLE IF NOT EXISTS item_backups (
item_id uuid REFERENCES items(id) ON DELETE CASCADE,
target text NOT NULL, -- 'nas' | delivery_target-UUID
status text NOT NULL DEFAULT 'mirrored' CHECK (status IN ('mirrored','failed')),
at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (item_id, target)
);
+18
View File
@@ -0,0 +1,18 @@
-- Zugriffsrechte, private Sessions, NSFW-Schalter.
-- Sichtbarkeit der Bibliothek + Datenschutz.
ALTER TABLE settings ADD COLUMN IF NOT EXISTS library_visibility text NOT NULL DEFAULT 'own'
CHECK (library_visibility IN ('own','shared')); -- own = jede/r sieht nur eigene; shared = alle sehen alles
ALTER TABLE settings ADD COLUMN IF NOT EXISTS anonymous_generations bool NOT NULL DEFAULT false;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS private_allowed bool NOT NULL DEFAULT false;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS allow_nsfw bool NOT NULL DEFAULT false;
-- Pro Nutzer: private Sessions erzwingen.
ALTER TABLE users ADD COLUMN IF NOT EXISTS private_forced bool NOT NULL DEFAULT false;
-- Private Aufträge: nur erzeugen, danach vergessen (nicht in Bibliothek, kein Delivery/Backup,
-- kein gespeicherter Prompt, Quellen sofort gelöscht, Ergebnis nach kurzer Zeit entfernt).
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS private bool NOT NULL DEFAULT false;
-- Modelle als NSFW-fähig markieren (nur bei aktivem allow_nsfw wähl-/sichtbar).
ALTER TABLE models ADD COLUMN IF NOT EXISTS nsfw bool NOT NULL DEFAULT false;
+4
View File
@@ -0,0 +1,4 @@
-- Farbmarkierungen wie bei Picdrop (Rot / Orange / Grün / Final).
ALTER TABLE items ADD COLUMN IF NOT EXISTS color_tag text
CHECK (color_tag IN ('red','orange','green','final'));
CREATE INDEX IF NOT EXISTS items_color_tag_idx ON items(color_tag);
+4
View File
@@ -0,0 +1,4 @@
-- Metadaten-Beileger (.md) pro Upload-Quelle steuerbar statt global.
ALTER TABLE settings ADD COLUMN IF NOT EXISTS picdrop_metadata_sidecar bool NOT NULL DEFAULT false;
ALTER TABLE settings ADD COLUMN IF NOT EXISTS nas_metadata_sidecar bool NOT NULL DEFAULT false;
ALTER TABLE delivery_targets ADD COLUMN IF NOT EXISTS metadata_sidecar bool NOT NULL DEFAULT false;
@@ -0,0 +1,11 @@
-- Aufträge dürfen jetzt auch „failed" sein (alle Positionen fehlgeschlagen) → rot in der Übersicht.
ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check;
ALTER TABLE jobs ADD CONSTRAINT jobs_status_check
CHECK (status IN ('queued','running','paused','done','cancelled','failed'));
-- Ausgabeformat der Bilddatei: global (settings) + optionaler Rezept-Override.
-- „png" = verlustfrei (Standard, nötig für Transparenz), „jpg" = klein/Picdrop-freundlich.
ALTER TABLE settings ADD COLUMN IF NOT EXISTS output_ext text NOT NULL DEFAULT 'png'
CHECK (output_ext IN ('png','jpg'));
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS output_ext text
CHECK (output_ext IN ('png','jpg'));
+14
View File
@@ -0,0 +1,14 @@
-- Vorlagen für Passbilder/Druckbogen („Kita-Satz", „8× Passbild") ----------
CREATE TABLE IF NOT EXISTS print_presets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
config jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS print_presets_name_idx ON print_presets(name);
-- Telegram: Zustand „wartet auf Druck-Auswahl" -----------------------------
ALTER TABLE telegram_drafts DROP CONSTRAINT IF EXISTS telegram_drafts_status_check;
ALTER TABLE telegram_drafts ADD CONSTRAINT telegram_drafts_status_check
CHECK (status IN ('collecting','awaiting_recipe','awaiting_compose_text','awaiting_print','dispatched','discarded'));
+548
View File
@@ -20,6 +20,7 @@
"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",
"pdf-lib": "^1.17.1",
"pg": "^8.13.1", "pg": "^8.13.1",
"pg-boss": "^10.1.5", "pg-boss": "^10.1.5",
"react": "^19.0.0", "react": "^19.0.0",
@@ -1763,6 +1764,24 @@
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@phc/format": { "node_modules/@phc/format": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@@ -6135,6 +6154,12 @@
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse-latin": { "node_modules/parse-latin": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz",
@@ -6196,6 +6221,24 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/pg": { "node_modules/pg": {
"version": "8.22.0", "version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
@@ -7430,6 +7473,511 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": {
"version": "4.23.12",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/tweetnacl": { "node_modules/tweetnacl": {
"version": "0.14.5", "version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+5 -3
View File
@@ -8,21 +8,23 @@
"build": "astro build", "build": "astro build",
"start": "node ./dist/server/entry.mjs", "start": "node ./dist/server/entry.mjs",
"migrate": "node --loader tsx ./scripts/migrate.mjs", "migrate": "node --loader tsx ./scripts/migrate.mjs",
"astro": "astro" "astro": "astro",
"test": "node --experimental-strip-types --test tests/*.test.ts"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^9.1.3", "@astrojs/node": "^9.1.3",
"@astrojs/react": "^4.2.1", "@astrojs/react": "^4.2.1",
"@fontsource/space-grotesk": "^5.1.0",
"@fontsource/space-mono": "^5.1.0",
"@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/s3-request-presigner": "^3.700.0", "@aws-sdk/s3-request-presigner": "^3.700.0",
"@fontsource/space-grotesk": "^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": "^5.5.0",
"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",
"pdf-lib": "^1.17.1",
"pg": "^8.13.1", "pg": "^8.13.1",
"pg-boss": "^10.1.5", "pg-boss": "^10.1.5",
"react": "^19.0.0", "react": "^19.0.0",
+273 -8
View File
@@ -8,20 +8,57 @@ export default function AdminApp() {
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<any[]>([]);
const [tgLinks, setTgLinks] = useState<any[]>([]); const [tgLinks, setTgLinks] = useState<any[]>([]);
const [toast, setToast] = useState<string | null>(null); const [toast, setToast] = useState<string | null>(null);
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState('');
const [storage, setStorage] = useState<any>(null);
const [targets, setTargets] = useState<any[]>([]);
const [nt, setNt] = useState<any>({ protocol: 'sftp' });
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
const load = useCallback(async () => { const load = useCallback(async () => {
const [a, b, c, d, e] = await Promise.all([ const [a, b, c, d, e, f, g] = await Promise.all([
fetch('/api/admin/settings').then((r) => r.json()), fetch('/api/admin/settings').then((r) => r.json()),
fetch('/api/admin/stats').then((r) => r.json()), fetch('/api/admin/stats').then((r) => r.json()),
fetch('/api/admin/models?available=1').then((r) => r.json()), fetch('/api/admin/models?available=1').then((r) => r.json()),
fetch('/api/admin/users').then((r) => r.json()), fetch('/api/admin/users').then((r) => r.json()),
fetch('/api/admin/telegram/links').then((r) => r.json()).catch(() => ({ links: [] })), fetch('/api/admin/telegram/links').then((r) => r.json()).catch(() => ({ links: [] })),
fetch('/api/admin/storage').then((r) => r.json()).catch(() => ({ stats: null })),
fetch('/api/admin/delivery-targets').then((r) => r.json()).catch(() => ({ targets: [] })),
]); ]);
setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []); setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []);
setUsers(d.users || []); setTgLinks(e.links || []); setUsers(d.users || []); setTgLinks(e.links || []); setStorage(f.stats || null); setTargets(g.targets || []);
}, []); }, []);
const addTarget = async () => {
if (!nt.name?.trim()) { notify('Name fehlt.'); return; }
await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(nt) });
setNt({ protocol: 'sftp' }); notify('Ziel angelegt.'); load();
};
const delTarget = async (id: string) => { if (!confirm('Ziel löschen?')) return; await fetch(`/api/admin/delivery-targets?id=${id}`, { method: 'DELETE' }); load(); };
const toggleBackup = async (t: any) => {
await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, is_backup: !t.is_backup }) });
load();
};
const toggleTargetSidecar = async (t: any) => {
await fetch('/api/admin/delivery-targets', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: t.id, metadata_sidecar: !t.metadata_sidecar }) });
load();
};
const backupAllTargets = async () => {
if (!confirm('Alle fertigen Bilder auf alle Backup-Ziele (NAS + markierte Ziele) sichern?')) return;
notify('Sichere … (kann dauern)');
const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'backup_all' }) }).then((x) => x.json());
notify(r.items != null ? `${r.mirrored} Kopien auf Backup-Ziele${r.failed ? `, ${r.failed} Fehler` : ''}.` : (r.error || 'OK'));
load();
};
const genToken = async () => {
await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ generate_api_token: true }) });
notify('API-Token erzeugt.'); load();
};
const testTarget = async (id: string) => {
notify('Teste Ziel …');
const r = await fetch('/api/admin/delivery-targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', id }) }).then((x) => x.json());
notify(r.message || (r.ok ? 'OK' : 'Fehler'));
};
const fmtBytes = (n: number | null) => n == null ? '—'
: n > 1e9 ? `${(n / 1e9).toFixed(2)} GB` : n > 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.round(n / 1e3)} KB`;
const pairCode = async (userId: string) => { const pairCode = async (userId: string) => {
const r = await fetch('/api/admin/telegram/pairing-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) }).then((x) => x.json()); const r = await fetch('/api/admin/telegram/pairing-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) }).then((x) => x.json());
@@ -39,18 +76,75 @@ export default function AdminApp() {
picdrop_host: s.picdrop_host, picdrop_protocol: s.picdrop_protocol, picdrop_port: s.picdrop_port, picdrop_host: s.picdrop_host, picdrop_protocol: s.picdrop_protocol, picdrop_port: s.picdrop_port,
picdrop_user: s.picdrop_user, picdrop_base_path: s.picdrop_base_path, picdrop_default_gallery: s.picdrop_default_gallery, picdrop_user: s.picdrop_user, picdrop_base_path: s.picdrop_base_path, picdrop_default_gallery: s.picdrop_default_gallery,
default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency, default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency,
output_ext: s.output_ext || 'png',
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url, cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
keep_sources: !!s.keep_sources, make_thumbnails: s.make_thumbnails !== false, retention_days: s.retention_days,
picdrop_metadata_sidecar: !!s.picdrop_metadata_sidecar, nas_metadata_sidecar: !!s.nas_metadata_sidecar,
library_visibility: s.library_visibility || 'own', anonymous_generations: !!s.anonymous_generations,
private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw,
nas_enabled: !!s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
nas_user: s.nas_user, nas_base_path: s.nas_base_path,
}; };
if (orKey.trim()) body.openrouter_key = orKey.trim(); if (orKey.trim()) body.openrouter_key = orKey.trim();
if (pdPw.trim()) body.picdrop_password = pdPw.trim(); if (pdPw.trim()) body.picdrop_password = pdPw.trim();
if (nasPw.trim()) body.nas_password = nasPw.trim();
await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
setOrKey(''); setPdPw(''); notify('Gespeichert.'); load(); setOrKey(''); setPdPw(''); setNasPw(''); notify('Gespeichert.'); load();
}; };
const testPicdrop = async () => { const testPicdrop = async () => {
notify('Teste …'); notify('Teste …');
const r = await fetch('/api/admin/test-picdrop', { method: 'POST' }).then((x) => x.json()); const r = await fetch('/api/admin/test-picdrop', { method: 'POST' }).then((x) => x.json());
notify(r.message || (r.ok ? 'OK' : 'Fehler')); notify(r.message || (r.ok ? 'OK' : 'Fehler'));
}; };
const [pdList, setPdList] = useState<any>(null);
const lsPicdrop = async (path?: string) => {
notify('Lese Picdrop-Ordner …');
const r = await fetch('/api/admin/picdrop-ls', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }) }).then((x) => x.json());
setPdList(r); notify(r.error ? r.error : `${(r.entries || []).length} Einträge in ${r.path}`);
};
const cleanupPicdrop = async () => {
const gallery = s.picdrop_default_gallery || 'POSTER LEA';
notify('Räume Reste auf …');
const r = await fetch('/api/admin/picdrop-ls', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'cleanup_tmp', gallery }) }).then((x) => x.json());
notify(r.error ? r.error : `${r.cleaned ?? 0} Reste in „${gallery}" entfernt.`);
};
const testNas = async () => {
notify('Teste NAS …');
const r = await fetch('/api/admin/test-nas', { method: 'POST' }).then((x) => x.json());
notify(r.message || (r.ok ? 'OK' : 'Fehler'));
};
const netcheck = async () => {
if (!s.nas_host) { notify('Erst Host eintragen und speichern.'); return; }
notify('Prüfe Erreichbarkeit …');
const r = await fetch('/api/admin/netcheck', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: s.nas_host, port: s.nas_port || 22 }) }).then((x) => x.json());
const dnsMsg = r.resolve ? (r.resolve.ok ? `DNS ✓ (${r.resolve.address})` : `DNS ✕ (${r.resolve.error})`) : '';
const tcpMsg = r.tcp ? (r.tcp.ok ? `Port erreichbar in ${r.tcp.ms}ms ✓` : `nicht erreichbar: ${r.tcp.error}`) : (r.error || 'Fehler');
notify(`${dnsMsg}${dnsMsg ? ' · ' : ''}${tcpMsg}`);
};
const storageAction = async (action: string, confirmMsg?: string) => {
if (confirmMsg && !confirm(confirmMsg)) return;
notify('Räume auf …');
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }) }).then((x) => x.json());
notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : r.made != null ? `${r.made} Vorschaubilder erzeugt.` : (r.error || 'OK'));
load();
};
const mirrorAll = async () => {
if (!s.nas_enabled) { notify('NAS erst aktivieren & speichern.'); return; }
if (!confirm('Alle fertigen Bilder aufs NAS sichern?')) return;
notify('Sichere aufs NAS … (kann dauern)');
const r = await fetch('/api/admin/storage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'mirror_all' }) }).then((x) => x.json());
notify(r.total != null ? `${r.mirrored}/${r.total} aufs NAS gesichert${r.failed ? `, ${r.failed} fehlgeschlagen` : ''}.` : (r.error || 'Fehler'));
load();
};
const resetAll = async () => {
const t = prompt('ACHTUNG: Löscht ALLE Bilder, Aufträge und Ordner unwiderruflich (Nutzer, Einstellungen, Modelle & Rezepte bleiben).\n\nZum Bestätigen RESET eingeben:');
if (t !== 'RESET') { if (t != null) notify('Abgebrochen — nicht bestätigt.'); return; }
notify('Setze zurück …');
const r = await fetch('/api/admin/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: 'RESET' }) }).then((x) => x.json());
notify(r.ok ? `Zurückgesetzt: ${r.deleted_items} Bilder entfernt.` : (r.error || 'Fehler'));
load();
};
const addModel = async (m: any) => { const addModel = async (m: any) => {
await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, await fetch('/api/admin/models', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: m.model_id, label: m.name, supports_alpha: m.supports_alpha, active: true }) }); body: JSON.stringify({ model_id: m.model_id, label: m.name, supports_alpha: m.supports_alpha, active: true }) });
@@ -65,6 +159,10 @@ export default function AdminApp() {
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, password: pw }) }); await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, password: pw }) });
notify('Passwort gesetzt.'); notify('Passwort gesetzt.');
}; };
const setUserPrivate = async (id: string, v: boolean) => {
await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, private_forced: v }) });
load();
};
const addUser = async () => { const addUser = async () => {
const username = prompt('Benutzername?'); if (!username) return; const username = prompt('Benutzername?'); if (!username) return;
const password = prompt('Passwort?'); if (!password) return; const password = prompt('Passwort?'); if (!password) return;
@@ -107,12 +205,21 @@ export default function AdminApp() {
<div className="feld"><label>Parallelität</label><input className="input" type="number" value={s.concurrency ?? 2} onChange={(e) => field('concurrency', e.target.value)} /></div> <div className="feld"><label>Parallelität</label><input className="input" type="number" value={s.concurrency ?? 2} onChange={(e) => field('concurrency', e.target.value)} /></div>
</div> </div>
<div className="feld"><label>Cricut-Bogenmaß (cm)</label><input className="input" value={s.cricut_sheet_cm ?? ''} onChange={(e) => field('cricut_sheet_cm', e.target.value)} /></div> <div className="feld"><label>Cricut-Bogenmaß (cm)</label><input className="input" value={s.cricut_sheet_cm ?? ''} onChange={(e) => field('cricut_sheet_cm', e.target.value)} /></div>
<div className="feld"><label>Standard-Dateiformat</label>
<select className="input" value={s.output_ext ?? 'png'} onChange={(e) => field('output_ext', e.target.value)}>
<option value="png">PNG (verlustfrei, groß)</option>
<option value="jpg">JPG (klein, Picdrop-freundlich)</option>
</select>
<div className="fein">Gilt für neue Umwandlungen ohne eigene Format-Wahl. Freigestellte Motive bleiben immer PNG.</div>
</div>
</div> </div>
</section> </section>
<section className="karte"> <section className="karte">
<div className="kopfzeile"><span className="mono-label">Picdrop</span> <div className="kopfzeile"><span className="mono-label">Picdrop</span>
<button className="mini" onClick={testPicdrop}>Verbindung testen</button></div> <div className="reihe"><button className="mini" onClick={() => lsPicdrop()}>Ordner anzeigen</button>
<button className="mini" onClick={cleanupPicdrop}>Reste aufräumen</button>
<button className="mini" onClick={testPicdrop}>Verbindung testen</button></div></div>
<div className="steuer"> <div className="steuer">
<div className="zwei"> <div className="zwei">
<div className="feld"><label>Host</label><input className="input" value={s.picdrop_host ?? ''} onChange={(e) => field('picdrop_host', e.target.value)} /></div> <div className="feld"><label>Host</label><input className="input" value={s.picdrop_host ?? ''} onChange={(e) => field('picdrop_host', e.target.value)} /></div>
@@ -130,7 +237,141 @@ export default function AdminApp() {
<div className="feld"><label>Basisordner (SFTP-Wurzel)</label><input className="input" value={s.picdrop_base_path ?? '/'} onChange={(e) => field('picdrop_base_path', e.target.value)} /></div> <div className="feld"><label>Basisordner (SFTP-Wurzel)</label><input className="input" value={s.picdrop_base_path ?? '/'} onChange={(e) => field('picdrop_base_path', e.target.value)} /></div>
<div className="feld"><label>Standard-Galerie</label><input className="input" placeholder="POSTER LEA" value={s.picdrop_default_gallery ?? ''} onChange={(e) => field('picdrop_default_gallery', e.target.value)} /></div> <div className="feld"><label>Standard-Galerie</label><input className="input" placeholder="POSTER LEA" value={s.picdrop_default_gallery ?? ''} onChange={(e) => field('picdrop_default_gallery', e.target.value)} /></div>
</div> </div>
<div className="fein">Bilder landen in <b>Basisordner / Galerie</b> z. B. <code>/ + POSTER LEA</code>. Pro Ordner lässt sich später eine eigene Galerie mappen.</div> <label className="schalt"><input type="checkbox" checked={!!s.picdrop_metadata_sidecar} onChange={(e) => field('picdrop_metadata_sidecar', e.target.checked)} />
<span><b>Metadaten (.md) an Picdrop mitschicken</b><em>Nur aktivieren, wenn Picdrop die Textdateien verarbeiten soll sonst aus lassen.</em></span></label>
<div className="fein">Bilder landen in <b>Basisordner / Galerie</b> z. B. <code>/ + POSTER LEA</code>. Ordner anzeigen" listet, was per SFTP tatsächlich wo liegt.</div>
{pdList && (
<div className="pdlist">
<div className="fein"><b>{pdList.path}</b>{pdList.error ? ` — ${pdList.error}` : ''}</div>
{(pdList.entries || []).map((e: any, i: number) => (
<div key={i} className="pdrow">
<button className="link2" onClick={() => lsPicdrop((pdList.path.replace(/\/$/, '')) + '/' + e.name)}>{e.type === 'd' || e.type === 2 ? '📁' : '📄'} {e.name}</button>
<span className="fein">{e.size ? `${Math.round(e.size / 1024)} KB` : ''}</span>
</div>
))}
</div>
)}
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Speicher & Aufräumen</span>
{storage && <span className="fein">{fmtBytes(storage.bytes)} · {storage.results} Ergebnisse · {storage.sources} Quellen · {storage.thumbs} Vorschau</span>}</div>
<div className="steuer">
<label className="schalt"><input type="checkbox" checked={s.make_thumbnails !== false} onChange={(e) => field('make_thumbnails', e.target.checked)} />
<span><b>Vorschaubilder erzeugen</b><em>Kleine, sparsame Bilder für die Bibliotheksvorschau.</em></span></label>
<label className="schalt"><input type="checkbox" checked={!s.keep_sources} onChange={(e) => field('keep_sources', !e.target.checked)} />
<span><b>Quellbilder nach Bearbeitung löschen</b><em>Spart Platz — Originale werden nach dem Ergebnis entfernt.</em></span></label>
<div className="fein">Metadaten-Beileger (.md mit Prompt/Modell/Format) lassen sich jetzt <b>pro Ziel</b> ein-/ausschalten — bei Picdrop, beim NAS und bei jedem FTP-Ziel einzeln (siehe die jeweilige Karte).</div>
<div className="feld"><label>Aufbewahrung (Tage)</label>
<input className="input" type="number" min={0} placeholder="leer = unbegrenzt" value={s.retention_days ?? ''} onChange={(e) => field('retention_days', e.target.value)} />
<div className="fein">Ältere Bilder werden automatisch (täglich) entfernt. Leer = nichts löschen.</div></div>
<div className="reihe">
<button className="mini" onClick={() => storageAction('purge_sources', 'Alle Quellbilder fertiger Positionen löschen? Ergebnisse bleiben erhalten.')}>Quellen jetzt löschen</button>
<button className="mini" onClick={() => storageAction('rebuild_thumbs')}>Vorschaubilder nachgenerieren</button>
{s.retention_days ? <button className="mini" onClick={() => storageAction('retention', `Positionen älter als ${s.retention_days} Tage jetzt löschen?`)}>Retention jetzt anwenden</button> : null}
</div>
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Synology-NAS (zweite Sicherung)</span>
<div className="reihe"><button className="mini" onClick={netcheck}>Erreichbarkeit</button>
<button className="mini" onClick={testNas}>Verbindung testen</button></div></div>
<div className="steuer">
<label className="schalt"><input type="checkbox" checked={!!s.nas_enabled} onChange={(e) => field('nas_enabled', e.target.checked)} />
<span><b>Ergebnisse zusätzlich aufs NAS spiegeln</b><em>Jedes fertige Bild wird nach <code>klarbild/JJJJ-MM/</code> gesichert.</em></span></label>
<div className="zwei">
<div className="feld"><label>Host</label><input className="input" placeholder="z. B. nas.local oder DDNS" value={s.nas_host ?? ''} onChange={(e) => field('nas_host', e.target.value)} /></div>
<div className="feld"><label>Port</label><input className="input" type="number" value={s.nas_port ?? 22} onChange={(e) => field('nas_port', e.target.value)} /></div>
</div>
<div className="zwei">
<div className="feld"><label>Protokoll</label>
<select className="input" value={s.nas_protocol ?? 'sftp'} onChange={(e) => field('nas_protocol', e.target.value)}>
<option value="sftp">SFTP (22)</option><option value="ftps">FTPS (21)</option></select></div>
<div className="feld"><label>Benutzer</label><input className="input" value={s.nas_user ?? ''} onChange={(e) => field('nas_user', e.target.value)} /></div>
</div>
<div className="feld"><label>Passwort {s.nas_password_set && <em>(gesetzt)</em>}</label>
<input className="input" type="password" placeholder={s.nas_password_set ? '••••••' : ''} value={nasPw} onChange={(e) => setNasPw(e.target.value)} /></div>
<div className="feld"><label>Basisordner auf dem NAS</label><input className="input" placeholder="/Klarbild" value={s.nas_base_path ?? ''} onChange={(e) => field('nas_base_path', e.target.value)} /></div>
<label className="schalt"><input type="checkbox" checked={!!s.nas_metadata_sidecar} onChange={(e) => field('nas_metadata_sidecar', e.target.checked)} />
<span><b>Metadaten (.md) aufs NAS mitschicken</b><em>Begleitende Textdatei (Prompt, Modell, Format …) neben jedem gesicherten Bild.</em></span></label>
<button className="mini" onClick={mirrorAll}>Alle vorhandenen Bilder aufs NAS sichern</button>
<div className="fein">Ergebnisse landen unter <code>Basisordner / JJJJ-MM /</code>. Zugangsdaten werden verschlüsselt gespeichert.</div>
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Weitere Ausgabeziele (FTP/SFTP)</span>
<button className="mini" onClick={backupAllTargets}>Alle Bilder sichern</button></div>
<div className="steuer">
<div className="fein">Zusätzliche FTP/SFTP-Ziele — z. B. eine zweite Picdrop-Galerie oder ein anderer Server.
Ein Preset/Ordner kann fest auf ein Ziel zeigen; im Studio unter „Wohin?" wählbar (auch NAS).
Backup"-Ziele erhalten automatisch <b>alle</b> Ergebnisse (wie das NAS).</div>
<div className="liste">
{targets.map((t) => (
<div key={t.id} className="zeile">
<div><b>{t.name}</b>{t.is_backup && <span className="pille">Backup</span>}{t.metadata_sidecar && <span className="pille alpha">.md</span>} <span className="fein">{t.protocol}://{t.username}@{t.host}:{t.port} · {t.base_path || '/'}{t.password_set ? '' : ' · ⚠︎ kein Passwort'}</span></div>
<div className="reihe">
<button className="mini" onClick={() => toggleBackup(t)}>{t.is_backup ? 'Backup aus' : 'Als Backup'}</button>
<button className="mini" onClick={() => toggleTargetSidecar(t)}>{t.metadata_sidecar ? '.md aus' : '.md an'}</button>
<button className="mini" onClick={() => testTarget(t.id)}>Test</button>
<button className="mini" onClick={() => delTarget(t.id)}>✕</button>
</div>
</div>
))}
{targets.length === 0 && <div className="fein">Noch keine zusätzlichen Ziele.</div>}
</div>
<div className="zwei">
<div className="feld"><label>Name</label><input className="input" placeholder="z. B. The Frame FTP" value={nt.name ?? ''} onChange={(e) => setNt({ ...nt, name: e.target.value })} /></div>
<div className="feld"><label>Protokoll</label>
<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>
</div>
<div className="zwei">
<div className="feld"><label>Host</label><input className="input" value={nt.host ?? ''} onChange={(e) => setNt({ ...nt, host: e.target.value })} /></div>
<div className="feld"><label>Port</label><input className="input" type="number" value={nt.port ?? ''} onChange={(e) => setNt({ ...nt, port: e.target.value })} /></div>
</div>
<div className="zwei">
<div className="feld"><label>Benutzer</label><input className="input" value={nt.username ?? ''} onChange={(e) => setNt({ ...nt, username: e.target.value })} /></div>
<div className="feld"><label>Passwort</label><input className="input" type="password" value={nt.password ?? ''} onChange={(e) => setNt({ ...nt, password: e.target.value })} /></div>
</div>
<div className="feld"><label>Basisordner</label><input className="input" placeholder="/" value={nt.base_path ?? ''} onChange={(e) => setNt({ ...nt, base_path: e.target.value })} /></div>
<label className="schalt"><input type="checkbox" checked={!!nt.is_backup} onChange={(e) => setNt({ ...nt, is_backup: e.target.checked })} />
<span><b>Als Backup-Ziel</b><em>Erhält automatisch alle Ergebnisse.</em></span></label>
<label className="schalt"><input type="checkbox" checked={!!nt.metadata_sidecar} onChange={(e) => setNt({ ...nt, metadata_sidecar: e.target.checked })} />
<span><b>Metadaten (.md) mitschicken</b><em>Begleitende Textdatei je Bild.</em></span></label>
<button className="mini" onClick={addTarget}>Ziel hinzufügen</button>
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Automatisierung / MCP-Zugriff</span></div>
<div className="steuer">
<div className="fein">API-Token für externen/programmatischen Zugriff (z. B. Klarbild-MCP: „nimm diese Bilder und verarbeite sie"). Als <code>Authorization: Bearer &lt;Token&gt;</code> an <code>/api/uploads</code> und <code>/api/jobs</code>.</div>
{s.api_token
? <div className="feld"><label>API-Token</label><input className="input" readOnly value={s.api_token} onFocus={(e) => e.target.select()} /></div>
: <div className="fein">Noch kein Token erzeugt.</div>}
<button className="mini" onClick={genToken}>{s.api_token ? 'Neuen Token erzeugen' : 'Token erzeugen'}</button>
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Rechte & Datenschutz</span></div>
<div className="steuer">
<div className="feld"><label>Wer sieht welche Generierungen?</label>
<div className="schalter">
<button className={s.library_visibility !== 'shared' ? 'an' : ''} onClick={() => field('library_visibility', 'own')}>Nur eigene</button>
<button className={s.library_visibility === 'shared' ? 'an' : ''} onClick={() => field('library_visibility', 'shared')}>Alle sehen alles</button>
</div>
<div className="fein">Admins sehen immer alles. Nur eigene": jede/r sieht ausschließlich die selbst erzeugten Bilder.</div>
</div>
<label className="schalt"><input type="checkbox" checked={!!s.anonymous_generations} onChange={(e) => field('anonymous_generations', e.target.checked)} />
<span><b>Generierungen anonym</b><em>Wer ein Bild erzeugt hat, wird für Nicht-Admins ausgeblendet.</em></span></label>
<label className="schalt"><input type="checkbox" checked={!!s.private_allowed} onChange={(e) => field('private_allowed', e.target.checked)} />
<span><b>Private Sessions erlauben</b><em>Nutzer bekommen im Studio den Schalter „Private Session" (nur erzeugen, danach vergessen).</em></span></label>
<label className="schalt"><input type="checkbox" checked={!!s.allow_nsfw} onChange={(e) => field('allow_nsfw', e.target.checked)} />
<span><b>NSFW-Modelle erlauben</b><em>Als NSFW markierte Modelle werden wähl-/sichtbar. Verantwortung liegt beim Betreiber; Inhalte mit Minderjährigen sind ausnahmslos untersagt.</em></span></label>
</div> </div>
</section> </section>
@@ -141,9 +382,10 @@ export default function AdminApp() {
<div className="liste"> <div className="liste">
{models.map((m) => ( {models.map((m) => (
<div key={m.id} className="zeile"> <div key={m.id} className="zeile">
<div><b>{m.label}</b>{m.is_default && <span className="pille">Standard</span>}{m.supports_alpha && <span className="pille alpha">transparent</span>}<div className="fein">{m.model_id}</div></div> <div><b>{m.label}</b>{m.is_default && <span className="pille">Standard</span>}{m.supports_alpha && <span className="pille alpha">transparent</span>}{m.nsfw && <span className="pille nsfw">NSFW</span>}<div className="fein">{m.model_id}</div></div>
<div className="reihe"> <div className="reihe">
{!m.is_default && <button className="mini" onClick={() => modelAction(m.id, 'default')}>Standard</button>} {!m.is_default && <button className="mini" onClick={() => modelAction(m.id, 'default')}>Standard</button>}
<button className="mini" onClick={() => modelAction(m.id, 'nsfw')}>{m.nsfw ? 'NSFW aus' : 'NSFW'}</button>
<button className="mini" onClick={() => modelAction(m.id, 'delete')}></button> <button className="mini" onClick={() => modelAction(m.id, 'delete')}></button>
</div> </div>
</div> </div>
@@ -162,8 +404,9 @@ export default function AdminApp() {
<div className="liste"> <div className="liste">
{users.map((u) => ( {users.map((u) => (
<div key={u.id} className="zeile"> <div key={u.id} className="zeile">
<div><b>{u.display_name || u.username}</b> <span className="fein">{u.username} · {u.role}</span></div> <div><b>{u.display_name || u.username}</b>{u.private_forced && <span className="pille">privat</span>} <span className="fein">{u.username} · {u.role}</span></div>
<div className="reihe"> <div className="reihe">
<button className="mini" onClick={() => setUserPrivate(u.id, !u.private_forced)}>{u.private_forced ? 'Privat aus' : 'Privat erzwingen'}</button>
<button className="mini" onClick={() => pairCode(u.id)}>Telegram koppeln</button> <button className="mini" onClick={() => pairCode(u.id)}>Telegram koppeln</button>
<button className="mini" onClick={() => setPw(u.id)}>Passwort setzen</button> <button className="mini" onClick={() => setPw(u.id)}>Passwort setzen</button>
</div> </div>
@@ -186,6 +429,15 @@ export default function AdminApp() {
</div> </div>
</section> </section>
<section className="karte gefahr">
<div className="kopfzeile"><span className="mono-label">Zurücksetzen</span></div>
<div className="steuer">
<div className="fein">Löscht <b>alle Bilder, Aufträge und Ordner</b> unwiderruflich, um sauber neu zu starten.
Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten. Tipp: vorher „Alle Bilder aufs NAS sichern".</div>
<button className="knopf warn" onClick={resetAll}>Bibliothek &amp; Aufträge zurücksetzen</button>
</div>
</section>
{toast && <div className="toast">{toast}</div>} {toast && <div className="toast">{toast}</div>}
<AdminStyles /> <AdminStyles />
</div> </div>
@@ -204,6 +456,10 @@ function AdminStyles() {
.input{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);} .input{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);}
.fein{font-size:11.5px;color:var(--soft);line-height:1.5;} .fein{font-size:11.5px;color:var(--soft);line-height:1.5;}
.knopf{border:none;border-radius:3px;padding:12px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;} .knopf{border:none;border-radius:3px;padding:12px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
.knopf.warn{background:var(--err);}
.karte.gefahr{border-color:color-mix(in oklab,var(--err) 40%,var(--line));}
.karte.gefahr .mono-label{color:var(--err);}
.steuer code{font-family:var(--font-mono);font-size:11px;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
.zahlen{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:16px;} .zahlen{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:16px;}
.zahlen div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;} .zahlen div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;}
.zahlen b{display:block;font-size:22px;line-height:1;}.zahlen span{font-size:11.5px;color:var(--soft);} .zahlen b{display:block;font-size:22px;line-height:1;}.zahlen span{font-size:11.5px;color:var(--soft);}
@@ -213,9 +469,18 @@ function AdminStyles() {
.zeile b{font-size:14px;} .zeile b{font-size:14px;}
.pille{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:20px;margin-left:6px;} .pille{font-family:var(--font-mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;background:var(--accent-bg);color:var(--accent);padding:2px 7px;border-radius:20px;margin-left:6px;}
.pille.alpha{background:var(--paper);color:var(--soft);} .pille.alpha{background:var(--paper);color:var(--soft);}
.pille.nsfw{background:var(--err);color:#fff;}
.schalt{display:flex;gap:10px;align-items:flex-start;cursor:pointer;}
.schalt input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
.schalt b{display:block;font-size:14px;}
.schalt em{display:block;font-style:normal;font-size:11.5px;color:var(--soft);margin-top:1px;line-height:1.4;}
.schalt code{font-family:var(--font-mono);font-size:11px;}
.avail{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;} .avail{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;}
.reihe{display:flex;gap:6px;} .reihe{display:flex;gap:6px;}
.mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);} .mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);}
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;} .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
.pdlist{background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:10px;margin-top:8px;max-height:260px;overflow:auto;}
.pdrow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 0;}
.link2{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:12px;text-align:left;padding:0;}
`}</style>; `}</style>;
} }
+241 -40
View File
@@ -1,18 +1,41 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
interface Item { id: string; filename: string; output_px: string; has_alpha: boolean; interface Item { id: string; filename: string; output_px: string; has_alpha: boolean;
folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; model_used: string; } folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; nas_status: string;
model_used: string; variant_of: string | null; mode: string; thumb_path: string | null; prompt_used: string | null;
color_tag: string | null; created_at: string; }
const TAGS = [
{ id: 'red', label: 'Rot', color: '#E5484D' },
{ id: 'orange', label: 'Orange', color: '#E8830C' },
{ id: 'green', label: 'Grün', color: '#46A758' },
{ id: 'final', label: 'Final', color: '#3A3733' },
];
const tagColor = (t: string | null) => TAGS.find((x) => x.id === t)?.color || null;
export default function LibraryApp() { export default function LibraryApp() {
const [items, setItems] = useState<Item[]>([]); const [items, setItems] = useState<Item[]>([]);
const [folders, setFolders] = useState<any[]>([]); const [folders, setFolders] = useState<any[]>([]);
const [models, setModels] = useState<any[]>([]); const [models, setModels] = useState<any[]>([]);
const modelLabel = (id: string) => models.find((m) => m.model_id === id)?.label || null; const [folderFilter, setFolderFilter] = useState<string | null>(null);
const [sel, setSel] = useState<Set<string>>(new Set()); const [pick, setPick] = useState<Set<string>>(new Set());
const [variantSel, setVariantSel] = useState<Record<string, string>>({});
const [compare, setCompare] = useState<Item | null>(null); const [compare, setCompare] = useState<Item | null>(null);
const [promptOf, setPromptOf] = useState<Item | null>(null);
const [saveView, setSaveView] = useState<Item | null>(null);
const [tagMenu, setTagMenu] = useState<string | null>(null);
const [tagFilter, setTagFilter] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null); const [toast, setToast] = useState<string | null>(null);
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); }; const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
// Modellname: Admin-Label, sonst lesbarer Rest der Modell-ID.
const modelName = (id: string | null) => {
if (!id) return null;
const m = models.find((x) => x.model_id === id);
if (m) return m.label;
return id.split('/').pop()!.replace(/-/g, ' ');
};
const load = useCallback(async () => { const load = useCallback(async () => {
const [i, f, m] = await Promise.all([ const [i, f, m] = await Promise.all([
fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })), fetch('/api/items').then((r) => r.json()).catch(() => ({ items: [] })),
@@ -25,67 +48,191 @@ export default function LibraryApp() {
const rename = async (id: string, filename: string) => const rename = async (id: string, filename: string) =>
fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) }); fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) });
const setTag = async (id: string, color_tag: string | null) => {
setItems((x) => x.map((y) => y.id === id ? { ...y, color_tag } : y));
setTagMenu(null);
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ color_tag }) });
};
const setFolder = async (id: string, folder_id: string | null) => { const setFolder = async (id: string, folder_id: string | null) => {
await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) }); await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
load(); load();
}; };
const del = async (id: string) => { await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems((x) => x.filter((y) => y.id !== id)); }; const del = async (id: string) => { await fetch(`/api/items/${id}`, { method: 'DELETE' }); setItems((x) => x.filter((y) => y.id !== id)); };
const delSelected = async () => { for (const id of sel) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setSel(new Set()); load(); notify('Gelöscht.'); }; const delSelected = async () => { for (const id of pick) await fetch(`/api/items/${id}`, { method: 'DELETE' }); setPick(new Set()); load(); notify('Gelöscht.'); };
const deliverSelected = async () => { const deliverSelected = async () => {
notify('Sende an Picdrop …'); notify('Sende an Picdrop …');
const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...sel] }) }).then((x) => x.json()); const r = await fetch('/api/deliver', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) }).then((x) => x.json());
setSel(new Set()); load(); notify(`${r.delivered}/${r.total} nach Picdrop ausgeliefert.`); setPick(new Set()); load(); notify(`${r.delivered}/${r.total} nach Picdrop ausgeliefert.`);
}; };
const toggle = (id: string) => setSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); const toggle = (id: string) => setPick((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const moveSelected = async (folder_id: string | null) => {
for (const id of pick) await fetch(`/api/items/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_id }) });
setPick(new Set()); load(); notify(folder_id ? 'In Ordner verschoben.' : 'Aus Ordner entfernt.');
};
const zipSelected = async () => {
notify('Packe ZIP …');
const res = await fetch('/api/items/zip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ itemIds: [...pick] }) });
if (!res.ok) { notify('ZIP fehlgeschlagen.'); return; }
const blob = await res.blob();
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = `klarbild_${new Date().toISOString().slice(0, 10)}.zip`; a.click();
URL.revokeObjectURL(a.href); notify('ZIP geladen.');
};
const alternative = async (id: string) => {
notify('Erzeuge Alternative …');
const r = await fetch(`/api/items/${id}/alternative`, { method: 'POST' }).then((x) => x.json());
if (r.ok) notify('Alternative wird erzeugt — erscheint gleich hier.'); else notify(r.error || 'Fehler.');
setTimeout(load, 2500);
};
// Ergebnis als neue Vorlage ins Studio übernehmen und weiterbearbeiten.
const reuse = (id: string) => { window.location.href = `/?reuse=${id}`; };
const newFolder = async () => { const newFolder = async () => {
const name = prompt('Name des Ordners?'); if (!name) return; const name = prompt('Name des Ordners?'); if (!name) return;
await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); const gallery = prompt('Picdrop-Galerie für diesen Ordner? (leer = Standard)') || '';
await fetch('/api/folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, picdrop_gallery: gallery.trim() || null }) });
load(); load();
}; };
const delFolder = async (id: string) => {
if (!confirm('Ordner löschen? Die Bilder bleiben erhalten.')) return;
await fetch(`/api/folders?id=${id}`, { method: 'DELETE' });
if (folderFilter === id) setFolderFilter(null);
load();
};
const renameFolder = async (o: any) => {
const name = prompt('Ordner umbenennen:', o.name); if (name == null) return;
const gallery = prompt('Picdrop-Galerie (leer = Standard):', o.picdrop_gallery || '');
await fetch('/api/folders', { method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: o.id, name: name.trim() || o.name, picdrop_gallery: (gallery || '').trim() || null }) });
load();
};
// Nach Ordner + Markierung filtern, dann Alternativen unter ihrem Ursprung gruppieren.
const filtered = items
.filter((v) => !folderFilter || v.folder_id === folderFilter)
.filter((v) => !tagFilter || v.color_tag === tagFilter);
const groupMap = new Map<string, Item[]>();
for (const it of filtered) {
const root = it.variant_of || it.id;
if (!groupMap.has(root)) groupMap.set(root, []);
groupMap.get(root)!.push(it);
}
const groups = [...groupMap.values()].map((g) => g.slice().sort((a, b) => +new Date(a.created_at) - +new Date(b.created_at)));
groups.sort((a, b) => +new Date(b[b.length - 1].created_at) - +new Date(a[a.length - 1].created_at));
const shown = (g: Item[]) => g.find((x) => x.id === variantSel[g[0].id]) || g[g.length - 1];
return ( return (
<div> <div>
<div className="kopfzeile"> <div className="kopfzeile">
<span className="mono-label">Bibliothek{items.length ? ` · ${items.length}` : ''}</span> <span className="mono-label">Bibliothek{filtered.length ? ` · ${filtered.length}` : ''}</span>
<div className="reihe"> <div className="reihe">
{sel.size > 0 && <> {pick.size > 0 && <>
<span className="fein">{sel.size} gewählt</span> <span className="fein">{pick.size} gewählt</span>
<button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button> <button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button>
<button className="mini" onClick={zipSelected}>ZIP laden</button>
<select className="mini-select" value="" onChange={(e) => { if (e.target.value) moveSelected(e.target.value === '__none' ? null : e.target.value); }}>
<option value="" disabled>In Ordner </option>
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
<option value="__none">Kein Ordner</option>
</select>
<button className="mini" onClick={delSelected}>Löschen</button> <button className="mini" onClick={delSelected}>Löschen</button>
</>} </>}
<button className="mini" onClick={newFolder}>Neuer Ordner</button> <button className="mini" onClick={newFolder}>Neuer Ordner</button>
</div> </div>
</div> </div>
{items.length === 0 ? ( {/* Ordner-Filter */}
<div className="leer"><span className="regmark" /><h3>Noch nichts erstellt</h3> <div className="ordnerleiste">
<p>Lade im Studio ein paar Screenshots hoch die Ergebnisse sammeln sich hier.</p></div> <button className={`chip ${!folderFilter ? 'an' : ''}`} onClick={() => setFolderFilter(null)}>Alle · {items.length}</button>
{folders.map((o) => (
<span key={o.id} className={`chip-wrap ${folderFilter === o.id ? 'an' : ''}`}>
<button className="chip" onClick={() => setFolderFilter(folderFilter === o.id ? null : o.id)}>
{o.name} · {o.count}{o.picdrop_gallery ? ' →' : ''}
</button>
{folderFilter === o.id && <>
<button className="chip-x" title="Ordner umbenennen" onClick={() => renameFolder(o)}></button>
<button className="chip-x del" title="Ordner löschen" onClick={() => delFolder(o.id)}></button>
</>}
</span>
))}
</div>
<div className="fein hinweis">Ordner gruppieren die Bibliothek und können je Ordner eine eigene Picdrop-Galerie bekommen (Pfeil = eigene Galerie hinterlegt).</div>
{/* Farbmarkierungen filtern */}
<div className="ordnerleiste tagbar">
<button className={`chip ${!tagFilter ? 'an' : ''}`} onClick={() => setTagFilter(null)}>Alle Markierungen</button>
{TAGS.map((t) => (
<button key={t.id} className={`chip flagchip ${tagFilter === t.id ? 'an' : ''}`} onClick={() => setTagFilter(tagFilter === t.id ? null : t.id)}>
<span className="flag" style={{ color: t.color }}></span> {t.label}
</button>
))}
</div>
{filtered.length === 0 ? (
<div className="leer"><span className="regmark" /><h3>{folderFilter ? 'Ordner ist leer' : 'Noch nichts erstellt'}</h3>
<p>{folderFilter ? 'Weise Bildern über das Ordner-Menü diesen Ordner zu.' : 'Lege im Studio los — die Ergebnisse sammeln sich hier.'}</p></div>
) : ( ) : (
<div className="galerie"> <div className="galerie">
{items.map((v) => ( {groups.map((g) => {
<article key={v.id} className={`kachel ${sel.has(v.id) ? 'gewaehlt' : ''}`}> const v = shown(g);
<div className="kachel-bild" onClick={() => toggle(v.id)}> const multi = g.length > 1;
<img src={`/api/items/${v.id}/file`} alt="" loading="lazy" /> return (
<span className="haken">{sel.has(v.id) ? '' : ''}</span> <article key={g[0].id} className={`kachel ${pick.has(v.id) ? 'gewaehlt' : ''}`}>
</div> <div className="kachel-bild" onClick={() => toggle(v.id)}>
<input className="name" defaultValue={v.filename || ''} onBlur={(e) => rename(v.id, e.target.value)} /> <img src={`/api/items/${v.id}/file?thumb=1`} alt="" loading="lazy" />
<div className="meta">{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}{modelLabel(v.model_used) ? ` · ${modelLabel(v.model_used)}` : ''} <span className="haken">{pick.has(v.id) ? '✓' : ''}</span>
{v.delivery_status === 'delivered' && <span className="dbadge ok">Picdrop </span>} {(v.mode === 'compose' || v.mode === 'generate') &&
{v.delivery_status === 'pending' && <span className="dbadge">Picdrop </span>} <span className="modetag">{v.mode === 'compose' ? 'kombiniert' : 'erzeugt'}</span>}
{v.delivery_status === 'failed' && <span className="dbadge err">Picdrop </span>} {v.color_tag && <span className="flagbadge" style={{ color: tagColor(v.color_tag)! }}></span>}
</div> {multi && <span className="fassung">Fassung {g.indexOf(v) + 1}/{g.length}</span>}
<div className="reihe knapp"> </div>
<button className="mini" onClick={() => setCompare(v)}>Vorher/Nachher</button>
<a className="mini" href={`/api/items/${v.id}/file?download=1`}>Laden</a> {multi && (
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}> <div className="varianten">
<option value="">Kein Ordner</option> {g.map((x, idx) => (
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)} <button key={x.id} className={`vthumb ${x.id === v.id ? 'an' : ''}`}
</select> onClick={() => setVariantSel((s) => ({ ...s, [g[0].id]: x.id }))} title={`Fassung ${idx + 1}`}>
<button className="mini" onClick={() => del(v.id)}></button> <img src={`/api/items/${x.id}/file?thumb=1`} alt="" loading="lazy" />
</div> </button>
</article> ))}
))} </div>
)}
<input className="name" defaultValue={v.filename || ''} key={v.id} onBlur={(e) => rename(v.id, e.target.value)} />
<div className="meta">{(v.tasks || []).join(' · ')}{v.output_px ? ` · ${v.output_px}px` : ''}{v.has_alpha ? ' · transparent' : ''}{modelName(v.model_used) ? ` · ${modelName(v.model_used)}` : ''}
{v.delivery_status === 'delivered' && <span className="dbadge ok">Picdrop </span>}
{v.delivery_status === 'pending' && <span className="dbadge">Picdrop </span>}
{v.delivery_status === 'failed' && <span className="dbadge err">Picdrop </span>}
{v.nas_status === 'mirrored' && <span className="dbadge ok">NAS </span>}
{v.nas_status === 'failed' && <span className="dbadge err">NAS </span>}
</div>
<div className="reihe knapp">
<div className="tagwrap">
<button className="mini flagbtn" style={v.color_tag ? { color: tagColor(v.color_tag)!, borderColor: tagColor(v.color_tag)! } : {}}
onClick={() => setTagMenu(tagMenu === v.id ? null : v.id)} title="Markierung"></button>
{tagMenu === v.id && (
<div className="tagmenu" onMouseLeave={() => setTagMenu(null)}>
<button onClick={() => setTag(v.id, null)}><span className="flag none"></span> Keine</button>
{TAGS.map((t) => (
<button key={t.id} onClick={() => setTag(v.id, t.id)}><span className="flag" style={{ color: t.color }}></span> {t.label}</button>
))}
</div>
)}
</div>
{v.prompt_used && <button className="mini" onClick={() => setPromptOf(v)}>Prompt</button>}
{v.mode !== 'generate' && <button className="mini" onClick={() => setCompare(v)}>Vorher/Nachher</button>}
<button className="mini stark2" onClick={() => setSaveView(v)} title="Groß öffnen, dann sichern (iPhone: In Fotos sichern)">Laden</button>
<button className="mini" onClick={() => reuse(v.id)} title="Ergebnis im Studio weiterbearbeiten">Weiter</button>
<button className="mini" onClick={() => alternative(v.id)} title="Weitere Fassung erzeugen">+ Alt.</button>
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}>
<option value="">Kein Ordner</option>
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
<button className="mini" onClick={() => del(v.id)}></button>
</div>
</article>
);
})}
</div> </div>
)} )}
@@ -97,7 +244,30 @@ export default function LibraryApp() {
<input type="range" min={0} max={100} defaultValue={50} <input type="range" min={0} max={100} defaultValue={50}
onChange={(e) => { const o = document.getElementById('ov'); if (o) o.style.clipPath = `inset(0 0 0 ${e.target.value}%)`; }} /> onChange={(e) => { const o = document.getElementById('ov'); if (o) o.style.clipPath = `inset(0 0 0 ${e.target.value}%)`; }} />
</div> </div>
<div className="lupe-fuss">{compare.filename} · {compare.output_px}px Regler: links Original, rechts Ergebnis</div> <div className="lupe-fuss">{compare.filename} · {compare.output_px}px Regler: links {compare.mode === 'compose' ? 'Vorlage' : 'Original'}, rechts Ergebnis</div>
</div>
)}
{saveView && (
<div className="lupe" onClick={() => setSaveView(null)}>
<div className="savewrap" onClick={(e) => e.stopPropagation()}>
<img className="saveimg blur" src={`/api/items/${saveView.id}/file?thumb=1`} alt="" />
<img className="saveimg full" src={`/api/items/${saveView.id}/file?preview=1`} alt={saveView.filename || ''} />
</div>
<div className="save-fuss" onClick={(e) => e.stopPropagation()}>
<span>📱 iPhone/iPad: lange auf das Bild tippen <b>In Fotos sichern"</b>.</span>
<a className="mini stark" href={`/api/items/${saveView.id}/file?download=1`}>Original als Datei (.png)</a>
<button className="mini" onClick={() => setSaveView(null)}>Schließen</button>
</div>
</div>
)}
{promptOf && (
<div className="lupe" onClick={() => setPromptOf(null)}>
<div className="promptbox" onClick={(e) => e.stopPropagation()}>
<div className="pb-kopf"><b>{promptOf.filename}</b>
<button className="mini" onClick={() => { navigator.clipboard?.writeText(promptOf.prompt_used || ''); notify('Prompt kopiert.'); }}>Kopieren</button></div>
<pre className="pb-text">{promptOf.prompt_used}</pre>
<div className="fein">{promptOf.model_used ? `Modell: ${modelName(promptOf.model_used)} · ` : ''}{promptOf.output_px}px</div>
</div>
</div> </div>
)} )}
{toast && <div className="toast">{toast}</div>} {toast && <div className="toast">{toast}</div>}
@@ -108,11 +278,17 @@ export default function LibraryApp() {
function LibStyles() { function LibStyles() {
return <style>{` return <style>{`
.kopfzeile{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap;} .kopfzeile{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
.reihe{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}.reihe.knapp{margin-top:8px;} .reihe{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}.reihe.knapp{margin-top:8px;}
.fein{font-size:11.5px;color:var(--soft);} .fein{font-size:11.5px;color:var(--soft);}
.hinweis{margin:0 0 14px;line-height:1.5;}
.mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 11px;cursor:pointer;font-family:inherit;font-size:12.5px;color:var(--ink);text-decoration:none;} .mini{background:var(--card);border:1px solid var(--line);border-radius:3px;padding:6px 11px;cursor:pointer;font-family:inherit;font-size:12.5px;color:var(--ink);text-decoration:none;}
.hidden{display:none;} .ordnerleiste{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-bottom:8px;}
.chip-wrap{display:inline-flex;align-items:center;border-radius:20px;}
.chip{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:6px 13px;cursor:pointer;font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;color:var(--soft);white-space:nowrap;}
.chip.an,.chip-wrap.an .chip{border-color:var(--accent);background:var(--accent-bg);color:var(--accent);}
.chip-x{border:none;background:none;color:var(--accent);cursor:pointer;font-size:12px;padding:0 4px;}
.chip-x.del{color:var(--err);padding:0 8px 0 2px;}
.leer{padding:60px 24px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px;} .leer{padding:60px 24px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px;}
.leer h3{margin:0;font-size:17px;}.leer p{margin:0;color:var(--soft);max-width:38ch;} .leer h3{margin:0;font-size:17px;}.leer p{margin:0;color:var(--soft);max-width:38ch;}
.galerie{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;align-items:start;} .galerie{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;align-items:start;}
@@ -125,9 +301,31 @@ function LibStyles() {
.kachel-bild:active img{transform:scale(.98);} .kachel-bild:active img{transform:scale(.98);}
.haken{position:absolute;top:6px;left:6px;width:21px;height:21px;border-radius:50%;border:1.5px solid #fff;background:rgba(22,21,15,.35);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;} .haken{position:absolute;top:6px;left:6px;width:21px;height:21px;border-radius:50%;border:1.5px solid #fff;background:rgba(22,21,15,.35);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;}
.kachel.gewaehlt .haken{background:var(--accent);} .kachel.gewaehlt .haken{background:var(--accent);}
.modetag{position:absolute;top:6px;right:6px;background:rgba(59,91,219,.92);color:#fff;font-family:var(--font-mono);font-size:9px;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:20px;}
.flagbadge{position:absolute;top:5px;left:6px;font-size:19px;line-height:1;text-shadow:0 1px 2px rgba(0,0,0,.35);}
.tagbar{margin:0 0 12px;}
.flagchip .flag{font-size:13px;vertical-align:-1px;}
.flag.none{color:var(--soft);}
.tagwrap{position:relative;display:inline-block;}
.flagbtn{font-size:14px;line-height:1;padding:5px 9px;}
.tagmenu{position:absolute;bottom:calc(100% + 4px);left:0;z-index:20;background:var(--card);border:1px solid var(--line);border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.16);padding:5px;display:flex;flex-direction:column;min-width:130px;}
.tagmenu button{display:flex;align-items:center;gap:8px;background:none;border:none;text-align:left;padding:7px 9px;cursor:pointer;font-family:inherit;font-size:13px;border-radius:4px;color:var(--ink);}
.tagmenu button:hover{background:var(--paper);}
.tagmenu .flag{font-size:15px;}
.savewrap{position:relative;max-width:94vw;max-height:78vh;display:flex;}
.saveimg{max-width:94vw;max-height:78vh;border-radius:4px;display:block;}
.saveimg.blur{filter:blur(8px);position:absolute;inset:0;width:100%;height:100%;object-fit:contain;}
.saveimg.full{position:relative;z-index:1;}
.save-fuss{display:flex;flex-direction:column;align-items:center;gap:10px;color:#EAEAE3;font-size:13.5px;text-align:center;max-width:90vw;}
.fassung{position:absolute;bottom:6px;right:6px;background:rgba(22,21,15,.7);color:#fff;font-family:var(--font-mono);font-size:9px;padding:2px 7px;border-radius:20px;}
.varianten{display:flex;gap:5px;margin-top:6px;overflow-x:auto;}
.vthumb{flex:0 0 auto;width:40px;height:40px;border:1px solid var(--line);border-radius:3px;overflow:hidden;padding:0;background:none;cursor:pointer;}
.vthumb.an{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
.vthumb img{width:100%;height:100%;object-fit:cover;display:block;}
.name{margin-top:8px;width:100%;font-size:12.5px;padding:6px 8px;font-family:var(--font-mono);border:1px solid var(--line);border-radius:3px;background:#fff;} .name{margin-top:8px;width:100%;font-size:12.5px;padding:6px 8px;font-family:var(--font-mono);border:1px solid var(--line);border-radius:3px;background:#fff;}
.meta{font-size:11px;color:var(--soft);margin-top:5px;} .meta{font-size:11px;color:var(--soft);margin-top:5px;}
.mini.stark{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;} .mini.stark{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;}
.mini.stark2{border-color:var(--accent);color:var(--accent);font-weight:600;}
.dbadge{display:inline-block;margin-left:6px;font-family:var(--font-mono);font-size:9px;letter-spacing:.08em;text-transform:uppercase;background:var(--paper);color:var(--soft);padding:1px 6px;border-radius:20px;} .dbadge{display:inline-block;margin-left:6px;font-family:var(--font-mono);font-size:9px;letter-spacing:.08em;text-transform:uppercase;background:var(--paper);color:var(--soft);padding:1px 6px;border-radius:20px;}
.dbadge.ok{background:var(--accent-bg);color:var(--accent);} .dbadge.ok{background:var(--accent-bg);color:var(--accent);}
.dbadge.err{color:var(--err);} .dbadge.err{color:var(--err);}
@@ -138,6 +336,9 @@ function LibStyles() {
.vergleich .oben{position:absolute;inset:0;clip-path:inset(0 0 0 50%);} .vergleich .oben{position:absolute;inset:0;clip-path:inset(0 0 0 50%);}
.vergleich input[type=range]{position:absolute;left:0;right:0;bottom:-34px;width:100%;} .vergleich input[type=range]{position:absolute;left:0;right:0;bottom:-34px;width:100%;}
.lupe-fuss{color:#EAEAE3;font-family:var(--font-mono);font-size:12px;margin-top:30px;text-align:center;} .lupe-fuss{color:#EAEAE3;font-family:var(--font-mono);font-size:12px;margin-top:30px;text-align:center;}
.promptbox{background:var(--card);border-radius:var(--radius);max-width:min(92vw,620px);width:100%;padding:16px;display:flex;flex-direction:column;gap:10px;}
.pb-kopf{display:flex;justify-content:space-between;align-items:center;gap:10px;}
.pb-text{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12.5px;line-height:1.5;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:12px;max-height:50vh;overflow:auto;margin:0;}
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;} .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:60;}
`}</style>; `}</style>;
} }
File diff suppressed because it is too large Load Diff
+38 -3
View File
@@ -5,10 +5,22 @@ const STATUS_DE: Record<string, string> = {
skipped: 'übersprungen', paused: 'angehalten', cancelled: 'abgebrochen', skipped: 'übersprungen', paused: 'angehalten', cancelled: 'abgebrochen',
}; };
/** Zeitpunkt hübsch: heute → „HH:MM", sonst „TT.MM. HH:MM". */
function fmtTime(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const hm = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const today = new Date();
const sameDay = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate();
return sameDay ? hm : `${d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })} ${hm}`;
}
export default function QueueApp({ jobId }: { jobId: string | null }) { export default function QueueApp({ jobId }: { jobId: string | null }) {
const [job, setJob] = useState<any>(null); const [job, setJob] = useState<any>(null);
const [items, setItems] = useState<any[]>([]); const [items, setItems] = useState<any[]>([]);
const [jobs, setJobs] = useState<any[]>([]); const [jobs, setJobs] = useState<any[]>([]);
const [saveView, setSaveView] = useState<any | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
if (jobId) { if (jobId) {
@@ -39,7 +51,8 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
<a key={j.id} className="jobrow" href={`/warteschlange?job=${j.id}`}> <a key={j.id} className="jobrow" href={`/warteschlange?job=${j.id}`}>
<span className={`punkt ${j.status}`} /> <span className={`punkt ${j.status}`} />
<b>{j.done_count}/{j.total}</b> <b>{j.done_count}/{j.total}</b>
<span className="fein">{STATUS_DE[j.status] || j.status} · {j.by_name || ''}</span> <span className="fein">{STATUS_DE[j.status] || j.status}{j.by_name ? ` · ${j.by_name}` : ''}
{j.finished_at && ['done', 'failed', 'cancelled'].includes(j.status) ? ` · fertig ${fmtTime(j.finished_at)}` : ''}</span>
</a> </a>
))} ))}
</div> </div>
@@ -58,12 +71,20 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
return ( return (
<div className="q"> <div className="q">
<div className="kopfzeile"> <div className="kopfzeile">
<span className="mono-label">Auftrag · {STATUS_DE[job?.status] || job?.status}</span> <span className="mono-label">Auftrag · {STATUS_DE[job?.status] || job?.status}
{job?.finished_at && ['done', 'failed', 'cancelled'].includes(job?.status) ? ` · beendet ${fmtTime(job.finished_at)}` : ''}</span>
<div className="reihe"> <div className="reihe">
{job?.status === 'running' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/pause`)}>Anhalten</button>} {job?.status === 'running' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/pause`)}>Anhalten</button>}
{job?.status === 'paused' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/resume`)}>Fortsetzen</button>} {job?.status === 'paused' && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/resume`)}>Fortsetzen</button>}
{failed > 0 && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/retry-failed`)}>Fehlgeschlagene erneut</button>} {failed > 0 && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/retry-failed`)}>Fehlgeschlagene erneut</button>}
{['queued', 'running', 'paused'].includes(job?.status) && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/cancel`)}>Abbrechen</button>} {['queued', 'running', 'paused'].includes(job?.status) && <button className="mini" onClick={() => act(`/api/jobs/${jobId}/cancel`)}>Abbrechen</button>}
{['done', 'failed', 'cancelled'].includes(job?.status) && (
<button className="mini" onClick={async () => {
if (!confirm('Diesen Auftrag löschen?')) return;
await fetch(`/api/jobs/${jobId}/delete`, { method: 'POST' });
location.href = '/warteschlange';
}}>Löschen</button>
)}
</div> </div>
</div> </div>
@@ -87,12 +108,22 @@ export default function QueueApp({ jobId }: { jobId: string | null }) {
{it.error_message && <span className="fein err">{it.error_message}</span>} {it.error_message && <span className="fein err">{it.error_message}</span>}
</div> </div>
<div className="iact"> <div className="iact">
{it.status === 'done' && <a className="mini" href={`/api/items/${it.id}/file?download=1`}>Laden</a>} {it.status === 'done' && <button className="mini" onClick={() => setSaveView(it)}>Laden</button>}
{it.status === 'failed' && <button className="mini" onClick={() => act(`/api/items/${it.id}/retry`)}>Erneut</button>} {it.status === 'failed' && <button className="mini" onClick={() => act(`/api/items/${it.id}/retry`)}>Erneut</button>}
</div> </div>
</div> </div>
))} ))}
</div> </div>
{saveView && (
<div className="lupe" onClick={() => setSaveView(null)}>
<img className="saveimg" src={`/api/items/${saveView.id}/file?preview=1`} alt="" onClick={(e) => e.stopPropagation()} />
<div className="save-fuss" onClick={(e) => e.stopPropagation()}>
<span>📱 iPhone/iPad: lange auf das Bild tippen In Fotos sichern".</span>
<a className="mini" href={`/api/items/${saveView.id}/file?download=1`}>Original als Datei (.png)</a>
<button className="mini" onClick={() => setSaveView(null)}>Schließen</button>
</div>
</div>
)}
<QStyles /> <QStyles />
</div> </div>
); );
@@ -122,6 +153,10 @@ function QStyles() {
.punkt.done{background:var(--ok);} .punkt.done{background:var(--ok);}
.punkt.failed{background:var(--err);} .punkt.failed{background:var(--err);}
.punkt.queued{background:var(--mark);} .punkt.queued{background:var(--mark);}
.lupe{position:fixed;inset:0;background:rgba(22,21,15,.9);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:20px;z-index:50;}
.saveimg{max-width:94vw;max-height:78vh;border-radius:4px;display:block;}
.save-fuss{display:flex;flex-direction:column;align-items:center;gap:10px;color:#EAEAE3;font-size:13.5px;text-align:center;}
.save-fuss .mini{text-decoration:none;}
.joblist{display:flex;flex-direction:column;} .joblist{display:flex;flex-direction:column;}
.jobrow{display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--line);text-decoration:none;color:var(--ink);} .jobrow{display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--line);text-decoration:none;color:var(--ink);}
p.fein{padding:16px;} p.fein{padding:16px;}
+347 -143
View File
@@ -22,8 +22,19 @@ const FORMATS: { id: string; label: string; cm?: [number, number]; screen?: [num
{ id: 'theframe', label: 'The Frame (16:9)', screen: [3840, 2160] }, { id: 'theframe', label: 'The Frame (16:9)', screen: [3840, 2160] },
{ id: 'hochformat', label: 'Hochformat (9:16)', screen: [2160, 3840] }, { id: 'hochformat', label: 'Hochformat (9:16)', screen: [2160, 3840] },
{ id: 'sticker', label: 'Sticker (freies Maß)', cm: [5, 5] }, { id: 'sticker', label: 'Sticker (freies Maß)', cm: [5, 5] },
{ id: 'custom', label: 'Eigenes Maß (cm)' },
]; ];
/** Freitext-Maß → Formatschlüssel + cm. „25x35" → 25×35 cm; „5" → 5×5 cm Sticker. */
function parseCustomFmt(s: string): { key: string; cm: [number, number] | null } {
const t = (s || '').trim().replace(',', '.');
const wh = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/i.exec(t);
if (wh) return { key: `${wh[1]}x${wh[2]}`, cm: [parseFloat(wh[1]), parseFloat(wh[2])] };
const n = /^(\d+(?:\.\d+)?)$/.exec(t);
if (n) return { key: `sticker${n[1]}`, cm: [parseFloat(n[1]), parseFloat(n[1])] };
return { key: '', cm: null };
}
const TASKS = [ const TASKS = [
{ id: 'clean', name: 'Bereinigen', hint: 'Rahmen, Wand und Shop-Oberfläche entfernen.' }, { id: 'clean', name: 'Bereinigen', hint: 'Rahmen, Wand und Shop-Oberfläche entfernen.' },
{ id: 'cutout', name: 'Freistellen', hint: 'Nur das Motiv, Hintergrund transparent.' }, { id: 'cutout', name: 'Freistellen', hint: 'Nur das Motiv, Hintergrund transparent.' },
@@ -31,19 +42,37 @@ const TASKS = [
{ id: 'contour', name: 'Kontur', hint: 'Weißen Stickerrand anlegen (nur mit Freistellen).' }, { id: 'contour', name: 'Kontur', hint: 'Weißen Stickerrand anlegen (nur mit Freistellen).' },
]; ];
type Mode = 'each' | 'compose' | 'generate';
const MODES: { id: Mode; name: string; hint: string }[] = [
{ id: 'each', name: 'Bereinigen', hint: 'Screenshots bereinigen, freistellen, aufs Format bringen.' },
{ id: 'compose', name: 'Umwandeln', hint: 'Ein Bild umwandeln (z. B. Foto → Ölgemälde) oder mehrere kombinieren — mit Text.' },
{ id: 'generate', name: 'Neu erzeugen', hint: 'Ein komplett neues Bild allein aus Text.' },
];
const px = (cm: number, dpi = 300) => Math.round((cm / 2.54) * dpi); const px = (cm: number, dpi = 300) => Math.round((cm / 2.54) * dpi);
interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean } interface Pic { id: string; src: string; name: string; source_path?: string; error?: string; uploading?: boolean }
export default function StudioApp({ recipes }: { recipes: any[] }) { export default function StudioApp({ recipes }: { recipes: any[] }) {
const [presets, setPresets] = useState<any[]>(recipes || []);
const [mode, setMode] = useState<Mode>('each');
const [pics, setPics] = useState<Pic[]>([]); const [pics, setPics] = useState<Pic[]>([]);
const [tasks, setTasks] = useState<string[]>(['clean', 'format']); const [tasks, setTasks] = useState<string[]>(['clean', 'format']);
const [format, setFormat] = useState('30x40'); const [format, setFormat] = useState('30x40');
const [portrait, setPortrait] = useState(true); const [portrait, setPortrait] = useState(true);
const [crop, setCrop] = useState<'crop' | 'extend'>('crop'); const [crop, setCrop] = useState<'crop' | 'extend'>('crop');
const [contourMm, setContourMm] = useState(3); const [contourMm, setContourMm] = useState(3);
const [customFmt, setCustomFmt] = useState(''); // Freitext-Maß für „Eigenes Maß"/„Sticker"
const [outputExt, setOutputExt] = useState<'' | 'png' | 'jpg'>(''); // '' = globale Standard-Einstellung
const [custom, setCustom] = useState(''); const [custom, setCustom] = useState('');
const [desc, setDesc] = useState('');
const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library'); const [delivery, setDelivery] = useState<'library' | 'picdrop' | 'both'>('library');
const [gallery, setGallery] = useState('');
const [targetId, setTargetId] = useState('');
const [targets, setTargets] = useState<any[]>([]);
const [presetSel, setPresetSel] = useState('');
const [me, setMe] = useState<any>({});
const [priv, setPriv] = useState(false);
const [models, setModels] = useState<any[]>([]); const [models, setModels] = useState<any[]>([]);
const [modelKey, setModelKey] = useState<string>(''); const [modelKey, setModelKey] = useState<string>('');
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
@@ -55,11 +84,22 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
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 === 'hochformat'); const theframeConflict = hasCutout && (format === 'theframe' || format === 'hochformat');
// Format ist in compose/generate immer verfügbar (ohne „Format"-Aufgabe).
const showFormat = mode === 'each' ? hasFormat : true;
const wantsFormat = mode === 'each' ? hasFormat : format !== 'keep';
const isFreeFmt = format === 'custom' || format === 'sticker';
const parsedFmt = isFreeFmt ? parseCustomFmt(customFmt) : { key: '', cm: null };
// Für „Sticker" ohne Eingabe gilt weiter 5×5 cm; für „Eigenes Maß" braucht es eine Eingabe.
const effCm: [number, number] | null = fmt?.screen ? null
: parsedFmt.cm || (format === 'sticker' ? [5, 5] : (fmt?.cm || null));
// Was tatsächlich als output_format gespeichert wird (z. B. „25x35", „sticker5").
const effFormat = isFreeFmt ? (parsedFmt.key || (format === 'sticker' ? 'sticker' : '')) : format;
const zielPx: [number, number] | null = fmt?.screen const zielPx: [number, number] | null = fmt?.screen
? (fmt.screen as [number, number]) ? (fmt.screen as [number, number])
: fmt?.cm : effCm
? (portrait ? [px(fmt.cm[0]), px(fmt.cm[1])] : [px(fmt.cm[1]), px(fmt.cm[0])]) ? (portrait ? [px(effCm[0]), px(effCm[1])] : [px(effCm[1]), px(effCm[0])])
: null; : null;
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
@@ -87,12 +127,13 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
useEffect(() => { useEffect(() => {
const onPaste = (e: ClipboardEvent) => { const onPaste = (e: ClipboardEvent) => {
if (mode === 'generate') return;
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/')); const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith('image/'));
if (imgs.length) upload(imgs.map((i) => i.getAsFile()!).filter(Boolean)); if (imgs.length) upload(imgs.map((i) => i.getAsFile()!).filter(Boolean));
}; };
window.addEventListener('paste', onPaste); window.addEventListener('paste', onPaste);
return () => window.removeEventListener('paste', onPaste); return () => window.removeEventListener('paste', onPaste);
}, [upload]); }, [upload, mode]);
useEffect(() => { useEffect(() => {
fetch('/api/models').then((r) => r.json()).then((j) => { fetch('/api/models').then((r) => r.json()).then((j) => {
@@ -100,48 +141,113 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
const def = ms.find((m: any) => m.is_default) || ms[0]; const def = ms.find((m: any) => m.is_default) || ms[0];
if (def) setModelKey(def.model_id); if (def) setModelKey(def.model_id);
}).catch(() => {}); }).catch(() => {});
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
fetch('/api/me').then((r) => r.json()).then((j) => { setMe(j); if (j.private_forced) setPriv(true); }).catch(() => {});
loadPresets();
}, []);
const loadPresets = () => fetch('/api/recipes').then((r) => r.json()).then((j) => setPresets(j.recipes || [])).catch(() => {});
const saveAsPreset = async () => {
const name = prompt('Name des Presets?'); if (!name?.trim()) return;
const order = ['clean', 'cutout', 'format', 'contour', 'deliver'];
const tasksOut = mode === 'each' ? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b)) : (wantsFormat ? ['format'] : []);
const body = {
name: name.trim(), mode, tasks: tasksOut, output_format: wantsFormat ? effFormat : 'keep',
orientation: portrait ? 'portrait' : 'landscape', crop_mode: crop, dpi: 300,
contour_mm: tasks.includes('contour') ? contourMm : null,
output_ext: outputExt || null,
custom_instruction: mode === 'each' ? (custom || null) : null, model_key: modelKey || null,
delivery, picdrop_gallery: gallery.trim() || null, delivery_target_id: targetId || null,
};
await fetch('/api/recipes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
notify('Preset gespeichert.'); loadPresets();
};
const deletePreset = async (id: string) => {
if (!id || !confirm('Preset löschen?')) return;
await fetch(`/api/recipes/${id}`, { method: 'DELETE' }); notify('Preset gelöscht.'); loadPresets();
};
// Ergebnis aus der Bibliothek übernehmen: ?reuse=<itemId> lädt es als Vorlage.
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const reuseId = params.get('reuse');
if (!reuseId) return;
(async () => {
const id = crypto.randomUUID();
setPics((p) => [...p, { id, src: `/api/items/${reuseId}/file?thumb=1`, name: 'weiterbearbeiten.png', uploading: true }]);
try {
const j = await fetch(`/api/items/${reuseId}/reuse`, { method: 'POST' }).then((r) => r.json());
setPics((p) => p.map((x) => x.id === id
? { ...x, uploading: false, source_path: j.source_path, name: j.filename || x.name, error: j.error } : x));
} catch {
setPics((p) => p.map((x) => x.id === id ? { ...x, uploading: false, error: 'Konnte Bild nicht laden' } : x));
}
history.replaceState(null, '', '/');
})();
}, []); }, []);
function toggleTask(id: string) { function toggleTask(id: string) {
setTasks((t) => { setTasks((t) => {
let next = t.includes(id) ? t.filter((x) => x !== id) : [...t, id]; let next = t.includes(id) ? t.filter((x) => x !== id) : [...t, id];
if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour'); // Kontur braucht Freistellen if (id === 'cutout' && !next.includes('cutout')) next = next.filter((x) => x !== 'contour');
if (id === 'contour' && next.includes('contour') && !next.includes('cutout')) next = next; // wird per disabled verhindert
return next; return next;
}); });
} }
function applyRecipe(r: any) { function applyRecipe(r: any) {
if (!r) return; if (!r) return;
if (['each', 'compose', 'generate'].includes(r.mode)) setMode(r.mode);
setTasks(r.tasks || ['clean', 'format']); setTasks(r.tasks || ['clean', 'format']);
setFormat(r.output_format || 'keep'); const of = r.output_format || 'keep';
// Freies Maß aus dem Rezept zurückholen (kein bekannter Schlüssel = Eigenes Maß).
if (of !== 'keep' && !FORMATS.some((f) => f.id === of)) {
setFormat('custom');
setCustomFmt(/^sticker[-_ ]?/i.test(of) ? of.replace(/^sticker[-_ ]?/i, '') : of);
} else setFormat(of);
setOutputExt(r.output_ext === 'jpg' || r.output_ext === 'png' ? r.output_ext : '');
setPortrait((r.orientation || 'portrait') !== 'landscape'); setPortrait((r.orientation || 'portrait') !== 'landscape');
setCrop(r.crop_mode === 'extend' ? 'extend' : 'crop'); setCrop(r.crop_mode === 'extend' ? 'extend' : 'crop');
setContourMm(Number(r.contour_mm) || 3); setContourMm(Number(r.contour_mm) || 3);
setCustom(r.custom_instruction || ''); setCustom(r.custom_instruction || '');
setDelivery(r.delivery || 'library'); setDelivery(r.delivery || 'library');
setGallery(r.picdrop_gallery || '');
setTargetId(r.delivery_target_id || '');
if (r.model_key) setModelKey(r.model_key); if (r.model_key) setModelKey(r.model_key);
} }
const ready = pics.filter((p) => p.source_path && !p.error); const ready = pics.filter((p) => p.source_path && !p.error);
const canRun = ready.length > 0 && !busy && tasks.length > 0 && !theframeConflict && // „Eigenes Maß" braucht eine gültige Eingabe, sonst kann nicht gestartet werden.
!(tasks.includes('contour') && !hasCutout); const freeFmtInvalid = wantsFormat && format === 'custom' && !parsedFmt.cm;
const canRun = !busy && !freeFmtInvalid && (
mode === 'each' ? (ready.length > 0 && tasks.length > 0 && !theframeConflict && !(tasks.includes('contour') && !hasCutout))
: mode === 'compose' ? (ready.length >= 1 && desc.trim().length > 0)
: desc.trim().length > 0
);
async function run() { async function run() {
if (!canRun) return; if (!canRun) return;
setBusy(true); setBusy(true);
const order = ['clean', 'cutout', 'format', 'contour', 'deliver'];
const tasksOut = mode === 'each'
? [...tasks].sort((a, b) => order.indexOf(a) - order.indexOf(b))
: (wantsFormat ? ['format'] : []);
const recipe = { const recipe = {
tasks: [...tasks].sort((a, b) => ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(a) - ['clean', 'cutout', 'format', 'contour', 'deliver'].indexOf(b)), tasks: tasksOut,
output_format: hasFormat ? format : 'keep', output_format: wantsFormat ? effFormat : 'keep',
orientation: portrait ? 'portrait' : 'landscape', orientation: portrait ? 'portrait' : 'landscape',
crop_mode: crop, dpi: 300, crop_mode: crop, dpi: 300,
contour_mm: tasks.includes('contour') ? contourMm : null, contour_mm: tasks.includes('contour') ? contourMm : null,
custom_instruction: custom || null, delivery, model_key: modelKey || null, output_ext: outputExt || null,
custom_instruction: mode === 'each' ? (custom || null) : null,
delivery, model_key: modelKey || null,
picdrop_gallery: (delivery !== 'library' && gallery.trim()) ? gallery.trim() : null,
delivery_target_id: (delivery !== 'library' && targetId) ? targetId : null,
}; };
const body: any = { recipe, mode, delivery, private: priv };
if (mode !== 'each') body.prompt_text = desc.trim();
body.sources = mode === 'generate' ? [] : ready.map((p) => ({ source_path: p.source_path, filename: p.name }));
try { try {
const res = await fetch('/api/jobs', { const res = await fetch('/api/jobs', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
body: JSON.stringify({ recipe, sources: ready.map((p) => ({ source_path: p.source_path, filename: p.name })) }),
}); });
const j = await res.json(); const j = await res.json();
if (j.jobId) location.href = `/warteschlange?job=${j.jobId}`; if (j.jobId) location.href = `/warteschlange?job=${j.jobId}`;
@@ -149,136 +255,218 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
} catch { notify('Netzwerkfehler.'); setBusy(false); } } catch { notify('Netzwerkfehler.'); setBusy(false); }
} }
const uploadHint = mode === 'compose'
? 'Ein Bild umwandeln oder mehrere kombinieren (z. B. Motiv + Foto von Frieda).'
: 'Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.';
return ( return (
<div className="studio"> <div className="studio">
<section className="karte"> {/* Modus-Umschalter */}
<div className="kopfzeile"> <div className="modus-leiste">
<span className="mono-label">Vorlagen{pics.length ? ` · ${pics.length}` : ''}</span> {MODES.map((m) => (
{pics.length > 0 && <button className="link" onClick={() => setPics([])}>alle entfernen</button>} <button key={m.id} className={`modus-tab ${mode === m.id ? 'an' : ''}`} onClick={() => setMode(m.id)}>
</div> <b>{m.name}</b><span>{m.hint}</span>
<div className="buehne">
{pics.length === 0 ? (
<div className={`drop ${dragging ? 'drag' : ''}`}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}>
<span className="regmark" />
<p>Bilder hierher ziehen,<br />mit <span className="kbd">/Strg + V</span> einfügen<br />oder zum Auswählen tippen.</p>
<p className="fein">Mehrere gleichzeitig, bis zu 100. PNG, JPG, WEBP, HEIC.</p>
</div>
) : (
<div className="mini-raster">
{pics.map((b) => (
<div key={b.id} className={`mini-bild ${b.error ? 'err' : ''}`}>
{b.src && <img src={b.src} alt="" />}
{b.uploading && <span className="lade" />}
{b.error && <span className="badge">!</span>}
<button onClick={() => setPics((x) => x.filter((y) => y.id !== b.id))}>×</button>
</div>
))}
<button className="mini-add" onClick={() => fileRef.current?.click()}>+</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
onChange={(e) => e.target.files && upload(e.target.files)} />
</div>
</section>
<section className="karte">
<div className="steuer">
{recipes?.length > 0 && (
<div className="feld">
<label>Rezept laden</label>
<select className="select" onChange={(e) => applyRecipe(recipes.find((r) => r.id === e.target.value))} defaultValue="">
<option value="" disabled>Voreinstellung wählen </option>
{recipes.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
)}
<div className="feld">
<label>Was soll passieren?</label>
<div className="modi">
{TASKS.map((m) => {
const on = tasks.includes(m.id);
const disabled = m.id === 'contour' && !hasCutout;
return (
<button key={m.id} disabled={disabled}
className={`modus ${on ? 'an' : ''} ${disabled ? 'aus' : ''}`}
onClick={() => toggleTask(m.id)}>
<b>{m.name}{on ? ' ✓' : ''}</b><span>{m.hint}</span>
</button>
);
})}
</div>
</div>
{hasFormat && (
<div className="feld">
<label>Ausgabeformat</label>
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
{FORMATS.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)}
</select>
{theframeConflict && <div className="fein warn">Freistellen + The Frame ergibt keinen Sinn bitte eins wählen.</div>}
{fmt?.cm && !fmt.screen && (
<div className="schalter">
<button className={portrait ? 'an' : ''} onClick={() => setPortrait(true)}>Hochformat</button>
<button className={!portrait ? 'an' : ''} onClick={() => setPortrait(false)}>Querformat</button>
</div>
)}
{zielPx && <div className="fein">Ergibt exakt {zielPx[0]} × {zielPx[1]} Pixel bei 300 dpi druckfertig.</div>}
{zielPx && (
<div className="schalter zart">
<button className={crop === 'crop' ? 'an' : ''} onClick={() => setCrop('crop')}>Zuschneiden</button>
<button className={crop === 'extend' ? 'an' : ''} onClick={() => setCrop('extend')}>Ränder ergänzen</button>
</div>
)}
</div>
)}
{tasks.includes('contour') && (
<div className="feld">
<label>Stickerrand (mm)</label>
<input className="input" type="number" min={0} max={20} step={0.5}
value={contourMm} onChange={(e) => setContourMm(Number(e.target.value))} />
</div>
)}
{models.length > 0 && (
<div className="feld">
<label>Qualität</label>
<div className="schalter wrap">
{models.map((m) => (
<button key={m.model_id} className={modelKey === m.model_id ? 'an' : ''}
onClick={() => setModelKey(m.model_id)} title={m.description || ''}>{m.label}</button>
))}
</div>
<div className="fein">{models.find((m) => m.model_id === modelKey)?.description || ''}</div>
</div>
)}
<div className="feld">
<label>Eigene Anweisung (optional)</label>
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
placeholder="z. B. „Mach den Hintergrund heller, sonst alles lassen.“" />
</div>
<div className="feld">
<label>Wohin?</label>
<div className="schalter">
<button className={delivery === 'library' ? 'an' : ''} onClick={() => setDelivery('library')}>Bibliothek</button>
<button className={delivery === 'picdrop' ? 'an' : ''} onClick={() => setDelivery('picdrop')}>Picdrop</button>
<button className={delivery === 'both' ? 'an' : ''} onClick={() => setDelivery('both')}>Beides</button>
</div>
</div>
<button className="knopf" disabled={!canRun} onClick={run}>
{busy ? <><span className="spin" />Wird angelegt </> : `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`}
</button> </button>
<div className="fein mitte">Läuft serverseitig weiter du kannst das Fenster schließen.</div> ))}
</div> </div>
</section>
<div className="raster">
{mode !== 'generate' && (
<section className="karte">
<div className="kopfzeile">
<span className="mono-label">{mode === 'compose' ? 'Bausteine' : 'Vorlagen'}{pics.length ? ` · ${pics.length}` : ''}</span>
{pics.length > 0 && <button className="link" onClick={() => setPics([])}>alle entfernen</button>}
</div>
<div className="buehne">
{pics.length === 0 ? (
<div className={`drop ${dragging ? 'drag' : ''}`}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); upload(e.dataTransfer.files); }}>
<span className="regmark" />
<p>Bilder hierher ziehen,<br />mit <span className="kbd">/Strg + V</span> einfügen<br />oder zum Auswählen tippen.</p>
<p className="fein">{uploadHint}</p>
</div>
) : (
<div className="mini-raster">
{pics.map((b) => (
<div key={b.id} className={`mini-bild ${b.error ? 'err' : ''}`}>
{b.src && <img src={b.src} alt="" />}
{b.uploading && <span className="lade" />}
{b.error && <span className="badge">!</span>}
<button onClick={() => setPics((x) => x.filter((y) => y.id !== b.id))}>×</button>
</div>
))}
<button className="mini-add" onClick={() => fileRef.current?.click()}>+</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*,.heic,.heif" multiple hidden
onChange={(e) => e.target.files && upload(e.target.files)} />
</div>
</section>
)}
<section className="karte">
<div className="steuer">
<div className="feld">
<label>Presets</label>
<div className="reihe-presets">
<select className="select" value={presetSel}
onChange={(e) => { setPresetSel(e.target.value); applyRecipe(presets.find((r) => r.id === e.target.value)); }}>
<option value="">Preset wählen </option>
{presets.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
{presetSel && <button className="pbtn" title="Preset löschen" onClick={() => { deletePreset(presetSel); setPresetSel(''); }}></button>}
<button className="pbtn" title="Aktuelle Einstellungen als Preset speichern" onClick={saveAsPreset}> speichern</button>
</div>
</div>
{(mode === 'compose' || mode === 'generate') && (
<div className="feld">
<label>{mode === 'compose' ? 'Was soll entstehen?' : 'Beschreibung des neuen Bildes'}</label>
<textarea className="area gross" rows={4} value={desc} onChange={(e) => setDesc(e.target.value)}
placeholder={mode === 'compose'
? 'z. B. „Lass das Foto wie ein Ölgemälde aussehen.“ oder „…​mit unserer Hündin Frieda am Beckenrand.“'
: 'z. B. „Ein minimalistisches Poster mit einem Olivenzweig auf sandfarbenem Grund.“'} />
<div className="fein">{mode === 'compose'
? 'Ein Bild = umwandeln (z. B. Stil ändern). Mehrere Bilder = erstes ist Leitszene, weitere liefern Personen/Motive.'
: 'Je genauer die Beschreibung, desto besser das Ergebnis.'}</div>
</div>
)}
{mode === 'each' && (
<div className="feld">
<label>Was soll passieren?</label>
<div className="modi">
{TASKS.map((m) => {
const on = tasks.includes(m.id);
const disabled = m.id === 'contour' && !hasCutout;
return (
<button key={m.id} disabled={disabled}
className={`modus ${on ? 'an' : ''} ${disabled ? 'aus' : ''}`}
onClick={() => toggleTask(m.id)}>
<b>{m.name}{on ? ' ✓' : ''}</b><span>{m.hint}</span>
</button>
);
})}
</div>
</div>
)}
{showFormat && (
<div className="feld">
<label>{mode === 'each' ? 'Ausgabeformat' : 'Format (optional)'}</label>
<select className="select" value={format} onChange={(e) => setFormat(e.target.value)}>
{FORMATS.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)}
</select>
{isFreeFmt && (
<>
<input className="input" value={customFmt} onChange={(e) => setCustomFmt(e.target.value)}
placeholder={format === 'sticker' ? 'z. B. 5 (=5×5 cm) oder 5x7' : 'z. B. 25x35 oder 5 für 5×5 cm'} />
{freeFmtInvalid && <div className="fein warn">Bitte ein Maß angeben, z. B. 25x35" oder „5".</div>}
</>
)}
{theframeConflict && <div className="fein warn">Freistellen + The Frame ergibt keinen Sinn bitte eins wählen.</div>}
{effCm && (
<div className="schalter">
<button className={portrait ? 'an' : ''} onClick={() => setPortrait(true)}>Hochformat</button>
<button className={!portrait ? 'an' : ''} onClick={() => setPortrait(false)}>Querformat</button>
</div>
)}
{zielPx && <div className="fein">Ergibt exakt {zielPx[0]} × {zielPx[1]} Pixel bei 300 dpi druckfertig.</div>}
{zielPx && mode !== 'generate' && (
<div className="schalter zart">
<button className={crop === 'crop' ? 'an' : ''} onClick={() => setCrop('crop')}>Zuschneiden</button>
<button className={crop === 'extend' ? 'an' : ''} onClick={() => setCrop('extend')}>Erweitern</button>
</div>
)}
{zielPx && mode !== 'generate' && crop === 'extend' && (
<div className="fein">Erweitern" erzeugt mehr Szene rund ums Motiv (Outpainting), statt zuzuschneiden — ideal für The Frame.</div>
)}
</div>
)}
<div className="feld">
<label>Dateiformat</label>
<div className="schalter">
<button className={outputExt === '' ? 'an' : ''} onClick={() => setOutputExt('')}>Standard</button>
<button className={outputExt === 'png' ? 'an' : ''} onClick={() => setOutputExt('png')}>PNG</button>
<button className={outputExt === 'jpg' ? 'an' : ''} onClick={() => setOutputExt('jpg')}>JPG</button>
</div>
<div className="fein">
{outputExt === 'jpg' ? 'JPG — klein & Picdrop-freundlich. Freigestellte Motive bleiben trotzdem PNG (Transparenz).'
: outputExt === 'png' ? 'PNG — verlustfrei, unterstützt Transparenz. Große Dateien.'
: 'Standard aus den Admin-Einstellungen (global gesetzt).'}
</div>
</div>
{mode === 'each' && tasks.includes('contour') && (
<div className="feld">
<label>Stickerrand (mm)</label>
<input className="input" type="number" min={0} max={20} step={0.5}
value={contourMm} onChange={(e) => setContourMm(Number(e.target.value))} />
</div>
)}
{models.length > 0 && (
<div className="feld">
<label>Qualität</label>
<div className="schalter wrap">
{models.map((m) => (
<button key={m.model_id} className={modelKey === m.model_id ? 'an' : ''}
onClick={() => setModelKey(m.model_id)} title={m.description || ''}>{m.label}</button>
))}
</div>
<div className="fein">{models.find((m) => m.model_id === modelKey)?.description || ''}</div>
</div>
)}
{mode === 'each' && (
<div className="feld">
<label>Eigene Anweisung (optional)</label>
<textarea className="area" rows={2} value={custom} onChange={(e) => setCustom(e.target.value)}
placeholder="z. B. Mach den Hintergrund heller, sonst alles lassen." />
</div>
)}
<div className="feld">
<label>Wohin?</label>
<div className="schalter">
<button className={delivery === 'library' ? 'an' : ''} onClick={() => setDelivery('library')}>Bibliothek</button>
<button className={delivery === 'picdrop' ? 'an' : ''} onClick={() => setDelivery('picdrop')}>Picdrop</button>
<button className={delivery === 'both' ? 'an' : ''} onClick={() => setDelivery('both')}>Beides</button>
</div>
{delivery !== 'library' && (
<div className="ziel">
<select className="select" value={targetId} onChange={(e) => setTargetId(e.target.value)}>
<option value="">Standard-Picdrop</option>
<option value="nas">NAS</option>
{targets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<input className="input" value={gallery} onChange={(e) => setGallery(e.target.value)}
placeholder="Galerie/Ordner (leer = Standard)" />
</div>
)}
</div>
{(me.private_allowed || me.private_forced) && (
<label className="privat">
<input type="checkbox" checked={priv} disabled={me.private_forced} onChange={(e) => setPriv(e.target.checked)} />
<span><b>Private Session {me.private_forced ? '(erzwungen)' : ''}</b>
<em>Nur erzeugen, nichts merken: kein Eintrag in der Bibliothek, keine Auslieferung/Sicherung, kein gespeicherter Prompt. Ergebnis kurz danach abrufbar, dann gelöscht.</em></span>
</label>
)}
<button className="knopf" disabled={!canRun} onClick={run}>
{busy ? <><span className="spin" />Wird angelegt …</>
: mode === 'each' ? `Loslegen${ready.length ? ` · ${ready.length} Bild${ready.length > 1 ? 'er' : ''}` : ''}`
: 'Bild erzeugen'}
</button>
<div className="fein mitte">Läuft serverseitig weiter — du kannst das Fenster schließen.</div>
</div>
</section>
</div>
{toast && <div className="toast">{toast}</div>} {toast && <div className="toast">{toast}</div>}
<StudioStyles /> <StudioStyles />
</div> </div>
@@ -287,8 +475,15 @@ export default function StudioApp({ recipes }: { recipes: any[] }) {
function StudioStyles() { function StudioStyles() {
return <style>{` return <style>{`
.studio{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;} .studio{display:flex;flex-direction:column;gap:16px;}
@media(max-width:820px){.studio{grid-template-columns:1fr;}} .modus-leiste{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;}
@media(max-width:560px){.modus-leiste{grid-template-columns:1fr;}}
.modus-tab{text-align:left;background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);padding:11px 13px;cursor:pointer;font-family:inherit;}
.modus-tab b{display:block;font-size:14px;font-weight:600;}
.modus-tab span{display:block;font-size:11px;color:var(--soft);margin-top:2px;line-height:1.4;}
.modus-tab.an{border-color:var(--accent);background:var(--accent-bg);}
.raster{display:grid;grid-template-columns:1.1fr .9fr;gap:18px;align-items:start;}
@media(max-width:820px){.raster{grid-template-columns:1fr;}}
.karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);} .karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
.kopfzeile{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);} .kopfzeile{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);}
.link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;} .link{background:none;border:none;cursor:pointer;color:var(--accent);font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;}
@@ -305,12 +500,13 @@ function StudioStyles() {
.mini-bild>button{position:absolute;top:3px;right:3px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(22,21,15,.75);color:#fff;cursor:pointer;font-size:14px;line-height:1;} .mini-bild>button{position:absolute;top:3px;right:3px;width:20px;height:20px;border:none;border-radius:50%;background:rgba(22,21,15,.75);color:#fff;cursor:pointer;font-size:14px;line-height:1;}
.mini-bild .badge{position:absolute;bottom:3px;left:3px;background:var(--err);color:#fff;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;} .mini-bild .badge{position:absolute;bottom:3px;left:3px;background:var(--err);color:#fff;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;}
.lade{position:absolute;inset:0;background:var(--paper);opacity:.6;} .lade{position:absolute;inset:0;background:var(--paper);opacity:.6;}
.mini-add{aspect-ratio:3/4;border:1.5px dashed var(--line);background:none;border-radius:3px;cursor:pointer;font-size:22px;color:var(--soft);} .mini-add{aspect-ratio:1;border:1.5px dashed var(--line);background:none;border-radius:3px;cursor:pointer;font-size:22px;color:var(--soft);}
.steuer{padding:16px;display:flex;flex-direction:column;gap:15px;} .steuer{padding:16px;display:flex;flex-direction:column;gap:15px;}
.feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;} .feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--soft);margin-bottom:7px;}
.input,.select,.area{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;} .input,.select,.area{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);outline:none;}
.input:focus,.select:focus,.area:focus{border-color:var(--accent);} .input:focus,.select:focus,.area:focus{border-color:var(--accent);}
.area{font-size:13.5px;line-height:1.5;resize:vertical;} .area{font-size:13.5px;line-height:1.5;resize:vertical;}
.area.gross{font-size:15px;line-height:1.55;}
.fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;} .fein{font-size:11.5px;color:var(--soft);margin-top:6px;line-height:1.5;}
.fein.mitte{text-align:center;} .fein.mitte{text-align:center;}
.fein.warn{color:var(--err);} .fein.warn{color:var(--err);}
@@ -324,6 +520,14 @@ function StudioStyles() {
.schalter button{flex:1;background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--soft);} .schalter button{flex:1;background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px;cursor:pointer;font-family:inherit;font-size:13px;color:var(--soft);}
.schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;} .schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
.schalter.zart button{font-size:12.5px;} .schalter.zart button{font-size:12.5px;}
.ziel{display:flex;flex-direction:column;gap:7px;margin-top:8px;}
.reihe-presets{display:flex;gap:6px;align-items:center;}
.reihe-presets .select{flex:1;}
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
.privat{display:flex;gap:10px;align-items:flex-start;cursor:pointer;background:var(--paper);border:1px solid var(--line);border-radius:4px;padding:11px;}
.privat input{margin-top:3px;width:16px;height:16px;accent-color:var(--accent);flex:0 0 auto;}
.privat b{display:block;font-size:13.5px;}
.privat em{display:block;font-style:normal;font-size:11.5px;color:var(--soft);margin-top:2px;line-height:1.45;}
.schalter.wrap{flex-wrap:wrap;} .schalter.wrap{flex-wrap:wrap;}
.schalter.wrap button{flex:1 1 auto;min-width:110px;} .schalter.wrap button{flex:1 1 auto;min-width:110px;}
.knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;} .knopf{width:100%;border:none;border-radius:3px;padding:13px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;}
+4
View File
@@ -14,7 +14,9 @@ const active = (href: string) => href === '/' ? path === '/' : path.startsWith(h
const nav = [ const nav = [
{ href: '/', label: 'Studio', icon: 'studio' }, { href: '/', label: 'Studio', icon: 'studio' },
{ href: '/bibliothek', label: 'Bibliothek', icon: 'library' }, { href: '/bibliothek', label: 'Bibliothek', icon: 'library' },
{ href: '/drucken', label: 'Drucken', icon: 'print' },
{ href: '/warteschlange', label: 'Warteschlange', icon: 'queue' }, { href: '/warteschlange', label: 'Warteschlange', icon: 'queue' },
{ href: '/anleitung', label: 'Hilfe', icon: 'help' },
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []), ...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []),
]; ];
@@ -24,6 +26,8 @@ const ICONS: Record<string, string> = {
library: `<svg viewBox="0 0 24 24" ${S}><rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.5"/></svg>`, library: `<svg viewBox="0 0 24 24" ${S}><rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.5"/></svg>`,
queue: `<svg viewBox="0 0 24 24" ${S}><path d="M9 6h11M9 12h11M9 18h7"/><circle cx="4.5" cy="6" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.15" fill="currentColor" stroke="none"/></svg>`, queue: `<svg viewBox="0 0 24 24" ${S}><path d="M9 6h11M9 12h11M9 18h7"/><circle cx="4.5" cy="6" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.15" fill="currentColor" stroke="none"/></svg>`,
admin: `<svg viewBox="0 0 24 24" ${S}><path d="M4 8h9M17 8h3M4 16h3M11 16h9"/><circle cx="15" cy="8" r="2.2"/><circle cx="9" cy="16" r="2.2"/></svg>`, admin: `<svg viewBox="0 0 24 24" ${S}><path d="M4 8h9M17 8h3M4 16h3M11 16h9"/><circle cx="15" cy="8" r="2.2"/><circle cx="9" cy="16" r="2.2"/></svg>`,
print: `<svg viewBox="0 0 24 24" ${S}><path d="M7 9V4.5h10V9"/><rect x="3.5" y="9" width="17" height="7" rx="2"/><path d="M7 14h10v5.5H7z"/></svg>`,
help: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.7.3-1 .8-1 1.6v.3"/><circle cx="12" cy="16.7" r="0.6" fill="currentColor" stroke="none"/></svg>`,
}; };
--- ---
<!doctype html> <!doctype html>
+70
View File
@@ -0,0 +1,70 @@
// Generische Datensicherung: spiegelt fertige Ergebnisse auf alle Backup-Ziele
// (NAS aus den Einstellungen + jedes delivery_target mit is_backup), optional mit
// begleitender Metadaten-.md-Datei. Struktur je Ziel: <Basisordner>/JJJJ-MM/.
import { one, query } from './db';
import { getObject } from './storage';
import { uploadBuffer, loadTargetConfig, type PicdropCfg } from './picdrop';
import { loadNasConfig } from './nas';
import { buildMetadataMd, sidecarName } from './metadata';
export interface BackupDest { key: string; label: string; cfg: PicdropCfg; sidecar: boolean; }
/** Alle aktiven Backup-Ziele einsammeln (inkl. Metadaten-Beileger-Flag pro Ziel). */
export async function backupDestinations(): Promise<BackupDest[]> {
const dests: BackupDest[] = [];
const nas = await loadNasConfig();
if (nas) {
const s = await one<any>('SELECT nas_metadata_sidecar FROM settings WHERE id=1');
dests.push({ key: 'nas', label: 'NAS', cfg: nas, sidecar: !!s?.nas_metadata_sidecar });
}
const targets = await query<any>(`SELECT id, name, metadata_sidecar FROM delivery_targets WHERE is_backup=true`);
for (const t of targets) {
const cfg = await loadTargetConfig(t.id);
if (cfg) dests.push({ key: t.id, label: t.name, cfg, sidecar: !!t.metadata_sidecar });
}
return dests;
}
/** Ein Item auf alle Backup-Ziele spiegeln. */
export async function backupItem(itemId: string): Promise<{ mirrored: number; failed: number }> {
const dests = await backupDestinations();
if (!dests.length) return { mirrored: 0, failed: 0 };
const it = await one<any>('SELECT id, result_path, filename, created_at FROM items WHERE id=$1', [itemId]);
if (!it?.result_path) return { mirrored: 0, failed: 0 };
const buf = await getObject(it.result_path);
const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7);
let md: Buffer | null = null;
const anySidecar = dests.some((d) => d.sidecar);
if (anySidecar) md = Buffer.from(await buildMetadataMd(itemId), 'utf8');
let mirrored = 0, failed = 0;
for (const d of dests) {
try {
await uploadBuffer(d.cfg, ym, it.filename || `${it.id}.png`, buf);
if (d.sidecar && md) await uploadBuffer(d.cfg, ym, sidecarName(it.filename), md);
await query(`INSERT INTO item_backups (item_id, target, status) VALUES ($1,$2,'mirrored')
ON CONFLICT (item_id, target) DO UPDATE SET status='mirrored', at=now()`, [itemId, d.key]);
if (d.key === 'nas') await query(`UPDATE items SET nas_status='mirrored' WHERE id=$1`, [itemId]);
mirrored++;
} catch (e: any) {
console.error('[backup]', d.label, itemId, e?.message || e);
await query(`INSERT INTO item_backups (item_id, target, status) VALUES ($1,$2,'failed')
ON CONFLICT (item_id, target) DO UPDATE SET status='failed', at=now()`, [itemId, d.key]);
if (d.key === 'nas') await query(`UPDATE items SET nas_status='failed' WHERE id=$1`, [itemId]);
failed++;
}
}
return { mirrored, failed };
}
/** Alle fertigen Bilder auf alle Backup-Ziele sichern (Nachsicherung). */
export async function backupAll(): Promise<{ items: number; mirrored: number; failed: number }> {
const dests = await backupDestinations();
if (!dests.length) return { items: 0, mirrored: 0, failed: 0 };
const rows = await query<{ id: string }>(
`SELECT id FROM items WHERE status='done' AND result_path IS NOT NULL ORDER BY created_at`);
let mirrored = 0, failed = 0;
for (const it of rows) { const r = await backupItem(it.id); mirrored += r.mirrored; failed += r.failed; }
return { items: rows.length, mirrored, failed };
}
+44 -5
View File
@@ -1,6 +1,6 @@
import { one, query } from './db'; import { one, query } from './db';
import { getObject } from './storage'; import { getObject } from './storage';
import { loadConfig, uploadBuffer } from './picdrop'; import { loadConfig, loadTargetConfig, uploadBuffer, type PicdropCfg } from './picdrop';
// Standard-Galerie wie initial gebrieft. // Standard-Galerie wie initial gebrieft.
const DEFAULT_GALLERY = process.env.PICDROP_DEFAULT_GALLERY || 'POSTER LEA'; const DEFAULT_GALLERY = process.env.PICDROP_DEFAULT_GALLERY || 'POSTER LEA';
@@ -17,18 +17,57 @@ async function galleryFor(item: { folder_id: string | null; job_id: string }): P
return s?.picdrop_default_gallery || DEFAULT_GALLERY; return s?.picdrop_default_gallery || DEFAULT_GALLERY;
} }
/** Liefert ein fertiges Item an die passende Picdrop-Galerie aus. */ export async function cfgForKey(key: string | null | undefined): Promise<PicdropCfg | null> {
if (!key) return null;
if (key === 'nas') { const { loadNasConfig } = await import('./nas'); return (await loadNasConfig()) as unknown as PicdropCfg; }
if (key === 'picdrop') return loadConfig();
return loadTargetConfig(key); // UUID eines Zusatzziels
}
/** Ziel-Zugang inkl. Quell-Schlüssel: 1) Ordner-Ziel 2) Rezept-Ziel 3) Standard-Picdrop. */
async function targetFor(item: { folder_id: string | null; job_id: string }): Promise<{ cfg: PicdropCfg; key: string } | null> {
if (item.folder_id) {
const f = await one<{ delivery_target_id: string | null }>('SELECT delivery_target_id FROM folders WHERE id=$1', [item.folder_id]).catch(() => null);
if (f?.delivery_target_id) { const c = await cfgForKey(f.delivery_target_id); if (c) return { cfg: c, key: f.delivery_target_id }; }
}
const j = await one<{ recipe_snapshot: any }>('SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]);
const tid = j?.recipe_snapshot?.delivery_target_id;
if (tid) { const c = await cfgForKey(tid); if (c) return { cfg: c, key: tid }; }
const c = await loadConfig();
return c ? { cfg: c, key: 'picdrop' } : null;
}
/** Ist der Metadaten-Beileger für diese Quelle aktiv? */
async function sidecarForKey(key: string): Promise<boolean> {
if (key === 'picdrop') { const s = await one<any>('SELECT picdrop_metadata_sidecar FROM settings WHERE id=1'); return !!s?.picdrop_metadata_sidecar; }
if (key === 'nas') { const s = await one<any>('SELECT nas_metadata_sidecar FROM settings WHERE id=1'); return !!s?.nas_metadata_sidecar; }
const t = await one<any>('SELECT metadata_sidecar FROM delivery_targets WHERE id=$1', [key]);
return !!t?.metadata_sidecar;
}
/** Liefert ein fertiges Item an die passende Picdrop-Galerie aus.
* Ausgeliefert wird das gespeicherte Ergebnis **im gewählten Format** (PNG/JPG laut
* globaler bzw. Rezept-Einstellung) — keine erzwungene Umwandlung mehr. */
export async function deliverItem(itemId: string): Promise<{ ok: boolean; message: string }> { export async function deliverItem(itemId: string): Promise<{ ok: boolean; message: string }> {
const it = await one<any>('SELECT id, result_path, filename, folder_id, job_id FROM items WHERE id=$1', [itemId]); const it = await one<any>('SELECT id, result_path, filename, folder_id, job_id FROM items WHERE id=$1', [itemId]);
if (!it?.result_path) return { ok: false, message: 'Kein Ergebnis vorhanden.' }; if (!it?.result_path) return { ok: false, message: 'Kein Ergebnis vorhanden.' };
const cfg = await loadConfig(); const target = await targetFor(it);
if (!cfg) { await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]); return { ok: false, message: 'Picdrop nicht konfiguriert.' }; } if (!target) { await query(`UPDATE items SET delivery_status='failed' WHERE id=$1`, [itemId]); return { ok: false, message: 'Kein Auslieferungsziel konfiguriert.' }; }
const { cfg, key } = target;
await query(`UPDATE items SET delivery_status='pending' WHERE id=$1`, [itemId]); await query(`UPDATE items SET delivery_status='pending' WHERE id=$1`, [itemId]);
try { try {
const gallery = await galleryFor(it); const gallery = await galleryFor(it);
const buf = await getObject(it.result_path); const buf = await getObject(it.result_path);
await uploadBuffer(cfg, gallery, it.filename || `${it.id}.png`, buf); const name = it.filename || `${it.id}.png`;
await uploadBuffer(cfg, gallery, name, buf);
// Optional: Metadaten als begleitende .md-Datei — pro Quelle steuerbar.
if (await sidecarForKey(key)) {
try {
const { buildMetadataMd, sidecarName } = await import('./metadata');
await uploadBuffer(cfg, gallery, sidecarName(name), Buffer.from(await buildMetadataMd(itemId), 'utf8'));
} catch (e) { console.error('[delivery] Metadaten-Beileger fehlgeschlagen', e); }
}
await query(`UPDATE items SET delivery_status='delivered', delivered_at=now() WHERE id=$1`, [itemId]); await query(`UPDATE items SET delivery_status='delivered', delivered_at=now() WHERE id=$1`, [itemId]);
return { ok: true, message: `Ausgeliefert nach „${gallery}".` }; return { ok: true, message: `Ausgeliefert nach „${gallery}".` };
} catch (e: any) { } catch (e: any) {
+11 -1
View File
@@ -45,7 +45,17 @@ export function resolveDimensions(input: ResolveInput): Dimensions | null {
let cm: [number, number] | undefined; let cm: [number, number] | undefined;
if (format in CM) cm = CM[format]; if (format in CM) cm = CM[format];
else if (format === 'sticker' || format === 'custom') cm = input.customCm ?? [5, 5]; else if (format === 'sticker' || format === 'custom') cm = input.customCm ?? [5, 5];
if (!cm) throw new Error(`Unbekanntes Format: ${format}`); else {
// Frei definierte Formate direkt aus dem Schlüssel lesen — keine DB-Migration nötig:
// „sticker5" / „sticker-7,5" → quadratisch N×N cm; „25x35" / „25×35" → B×H cm.
const sq = /^sticker[-_ ]?(\d+(?:[.,]\d+)?)$/i.exec(format);
const wh = /^(\d+(?:[.,]\d+)?)\s*[x×*]\s*(\d+(?:[.,]\d+)?)$/i.exec(format);
const num = (s: string) => parseFloat(s.replace(',', '.'));
if (sq) { const n = num(sq[1]); cm = [n, n]; }
else if (wh) cm = [num(wh[1]), num(wh[2])];
}
if (!cm || !cm[0] || !cm[1] || cm[0] > 300 || cm[1] > 300)
throw new Error(`Unbekanntes Format: ${format}`);
// Hochformat-Konvention → gewünschte Orientierung anwenden // Hochformat-Konvention → gewünschte Orientierung anwenden
let [wCm, hCm] = cm; let [wCm, hCm] = cm;
+2
View File
@@ -15,6 +15,8 @@ export function ensureInit(): Promise<void> {
try { await startImageWorker(); } catch (e) { console.error('[init] Worker-Start fehlgeschlagen:', e); } try { await startImageWorker(); } catch (e) { console.error('[init] Worker-Start fehlgeschlagen:', e); }
try { const { setupTelegram } = await import('./telegram'); await setupTelegram(); } try { const { setupTelegram } = await import('./telegram'); await setupTelegram(); }
catch (e) { console.error('[init] Telegram-Setup fehlgeschlagen:', e); } catch (e) { console.error('[init] Telegram-Setup fehlgeschlagen:', e); }
try { const { startMaintenance } = await import('./maintenance'); startMaintenance(); }
catch (e) { console.error('[init] Wartung-Start fehlgeschlagen:', e); }
console.log('[init] Klarbild bereit.'); console.log('[init] Klarbild bereit.');
})().catch((e) => { started = null; throw e; }); })().catch((e) => { started = null; throw e; });
} }
+130
View File
@@ -0,0 +1,130 @@
// Speicherverwaltung: Nutzung anzeigen, Aufräumen (Retention), Quellen löschen.
// Ziel: „den Server nicht vollmüllen".
import { stat, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { one, query } from './db';
import { deleteObject } from './storage';
const DRIVER = (process.env.STORAGE_DRIVER || 'fs').toLowerCase();
const DIR = process.env.STORAGE_DIR || '/data';
async function dirBytes(path: string): Promise<{ bytes: number; files: number }> {
let bytes = 0, files = 0;
let entries: any[] = [];
try { entries = await readdir(path, { withFileTypes: true }); } catch { return { bytes, files }; }
for (const e of entries) {
const p = join(path, e.name);
if (e.isDirectory()) { const s = await dirBytes(p); bytes += s.bytes; files += s.files; }
else { try { const st = await stat(p); bytes += st.size; files++; } catch { /* ignore */ } }
}
return { bytes, files };
}
export interface StorageStats {
driver: string; bytes: number | null; files: number | null;
sources: number; results: number; thumbs: number; items: number;
}
/** Speicher-Kennzahlen. Byte-Genauigkeit nur beim fs-Treiber. */
export async function storageStats(): Promise<StorageStats> {
const counts = await one<any>(`SELECT
count(*) FILTER (WHERE source_path IS NOT NULL)::int AS sources,
count(*) FILTER (WHERE result_path IS NOT NULL)::int AS results,
count(*) FILTER (WHERE thumb_path IS NOT NULL)::int AS thumbs,
count(*)::int AS items FROM items`);
let bytes: number | null = null, files: number | null = null;
if (DRIVER === 'fs') { const s = await dirBytes(DIR); bytes = s.bytes; files = s.files; }
return { driver: DRIVER, bytes, files,
sources: counts?.sources || 0, results: counts?.results || 0,
thumbs: counts?.thumbs || 0, items: counts?.items || 0 };
}
/** Löscht Positionen (samt Objekten), die älter als N Tage sind. */
export async function runRetention(days: number): Promise<{ deleted: number }> {
if (!days || days <= 0) return { deleted: 0 };
const rows = await query<any>(
`SELECT id, source_path, source_paths, result_path, thumb_path FROM items
WHERE created_at < now() - ($1 || ' days')::interval`, [String(days)]);
for (const it of rows) {
const keys = [it.source_path, it.result_path, it.thumb_path,
...((it.source_paths as string[]) || [])].filter(Boolean);
for (const k of keys) await deleteObject(k).catch(() => {});
await query('DELETE FROM items WHERE id=$1', [it.id]);
}
return { deleted: rows.length };
}
/** Erzeugt fehlende Vorschaubilder nach (für schnelle Bibliotheks-Anzeige). */
export async function rebuildThumbs(): Promise<{ made: number }> {
const { getObject, putObject, thumbKey } = await import('./storage');
const sharp = (await import('sharp')).default;
const rows = await query<any>(
`SELECT id, result_path FROM items WHERE status='done' AND result_path IS NOT NULL AND thumb_path IS NULL`);
let made = 0;
for (const it of rows) {
try {
const out = await sharp(await getObject(it.result_path), { failOn: 'none' })
.resize(600, 600, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 72 }).toBuffer();
const tk = thumbKey(it.id);
await putObject(tk, out, 'image/webp');
await query(`UPDATE items SET thumb_path=$2 WHERE id=$1`, [it.id, tk]);
made++;
} catch (e) { console.error('[maintenance] Thumb', it.id, e); }
}
return { made };
}
/** Löscht nur die Quellbilder fertiger Positionen (Ergebnisse bleiben). */
export async function purgeSources(): Promise<{ purged: number }> {
const rows = await query<any>(
`SELECT id, source_path, source_paths FROM items WHERE status='done'
AND (source_path IS NOT NULL OR source_paths IS NOT NULL)`);
let purged = 0;
for (const it of rows) {
const keys = [it.source_path, ...((it.source_paths as string[]) || [])].filter(Boolean);
for (const k of keys) await deleteObject(k).catch(() => {});
await query('UPDATE items SET source_path=NULL, source_paths=NULL WHERE id=$1', [it.id]);
purged += keys.length;
}
return { purged };
}
/** Spiegelt alle fertigen Bilder auf alle Backup-Ziele (NAS + is_backup-Ziele). */
export async function mirrorAllToNas(): Promise<{ mirrored: number; failed: number; total: number }> {
const { backupAll } = await import('./backup');
const r = await backupAll();
return { mirrored: r.mirrored, failed: r.failed, total: r.items };
}
/** Private Aufträge „vergessen": Ergebnisse + Aufträge nach kurzer Zeit löschen. */
export async function purgePrivate(olderThanMin = 60): Promise<{ deleted: number }> {
const rows = await query<any>(
`SELECT i.id, i.result_path, i.thumb_path FROM items i JOIN jobs j ON j.id=i.job_id
WHERE j.private=true AND i.created_at < now() - ($1 || ' minutes')::interval`, [String(olderThanMin)]);
for (const it of rows) {
for (const k of [it.result_path, it.thumb_path].filter(Boolean)) await deleteObject(k).catch(() => {});
await query('DELETE FROM items WHERE id=$1', [it.id]);
}
// leere private Aufträge entfernen
await query(`DELETE FROM jobs WHERE private=true AND id NOT IN (SELECT DISTINCT job_id FROM items WHERE job_id IS NOT NULL)`);
return { deleted: rows.length };
}
let timer: NodeJS.Timeout | null = null;
/** Wartungs-Lauf: Retention + private Sessions vergessen. */
export function startMaintenance(): void {
if (timer) return;
const tick = async () => {
try {
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
if (s?.retention_days && s.retention_days > 0) {
const r = await runRetention(s.retention_days);
if (r.deleted) console.log(`[maintenance] Retention: ${r.deleted} alte Positionen entfernt.`);
}
const p = await purgePrivate(60);
if (p.deleted) console.log(`[maintenance] Privat: ${p.deleted} Ergebnisse vergessen.`);
} catch (e) { console.error('[maintenance]', e); }
};
timer = setInterval(tick, 30 * 60 * 1000); // alle 30 min (auch für private Purge)
setTimeout(tick, 60 * 1000); // erster Lauf nach 1 min
}
+39
View File
@@ -0,0 +1,39 @@
import { one } from './db';
/** Baut eine begleitende Metadaten-Datei (Markdown) zu einer Position. */
export async function buildMetadataMd(itemId: string): Promise<string> {
const it = await one<any>(
`SELECT i.filename, i.output_px, i.has_alpha, i.model_used, i.prompt_used, i.cost,
i.created_at, i.delivery_status, i.color_tag, j.mode, j.recipe_snapshot
FROM items i JOIN jobs j ON j.id=i.job_id WHERE i.id=$1`, [itemId]);
if (!it) return '';
const r = it.recipe_snapshot || {};
const tasks = Array.isArray(r.tasks) ? r.tasks.join(', ') : '';
const TAG: Record<string, string> = { red: 'Rot', orange: 'Orange', green: 'Grün', final: 'Final' };
const lines = [
`# ${it.filename || itemId}`,
'',
`- **Erzeugt:** ${new Date(it.created_at).toISOString()}`,
`- **Modus:** ${it.mode || 'each'}`,
tasks ? `- **Aufgaben:** ${tasks}` : '',
r.output_format ? `- **Format:** ${r.output_format}${r.orientation ? ` (${r.orientation})` : ''}` : '',
it.output_px ? `- **Auflösung:** ${it.output_px} px${r.dpi ? ` @ ${r.dpi} dpi` : ''}` : '',
`- **Transparenz:** ${it.has_alpha ? 'ja' : 'nein'}`,
it.model_used ? `- **Modell:** ${it.model_used}` : '',
it.color_tag ? `- **Markierung:** ${TAG[it.color_tag] || it.color_tag}` : '',
it.cost != null ? `- **Kosten:** $${Number(it.cost).toFixed(4)}` : '',
'',
'## Prompt',
'',
'```',
(it.prompt_used || '(kein Prompt gespeichert)'),
'```',
].filter((l) => l !== '');
return lines.join('\n') + '\n';
}
/** Dateiname des Beilegers: <ergebnis>.md */
export function sidecarName(filename: string | null): string {
const base = (filename || 'klarbild').replace(/\.[^.]+$/, '');
return `${base}.md`;
}
+89
View File
@@ -0,0 +1,89 @@
// Zweite Datensicherung auf ein Synology-NAS (SFTP oder FTPS, wie Picdrop).
// Ergebnisse werden zusätzlich zur Bibliothek/Picdrop auf das NAS gespiegelt.
import posixpath from 'node:path/posix';
import { one, query } from './db';
import { decrypt } from './crypto';
import { getObject } from './storage';
export interface NasCfg {
host: string; protocol: 'ftps' | 'sftp'; port: number;
user: string; password: string; basePath: string;
}
export async function loadNasConfig(): Promise<NasCfg | null> {
const s = await one<any>(`SELECT nas_enabled, nas_host, nas_protocol, nas_port, nas_user,
nas_password_enc, nas_base_path FROM settings WHERE id=1`);
if (!s?.nas_enabled || !s?.nas_host || !s?.nas_user || !s?.nas_password_enc) return null;
let password = '';
try { password = decrypt(s.nas_password_enc); } catch { return null; }
return {
host: s.nas_host, protocol: (s.nas_protocol || 'sftp'),
port: s.nas_port || (s.nas_protocol === 'ftps' ? 21 : 22),
user: s.nas_user, password, basePath: s.nas_base_path || '/',
};
}
export async function testNas(cfg: NasCfg): Promise<{ ok: boolean; message: string }> {
try {
if (cfg.protocol === 'sftp') {
const SftpClient = (await import('ssh2-sftp-client')).default;
const c = new SftpClient();
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 });
await c.list(cfg.basePath || '/');
await c.end();
} else {
const { Client } = await import('basic-ftp');
const c = new Client(15000);
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
await c.list(cfg.basePath || '/');
c.close();
}
return { ok: true, message: 'NAS-Verbindung erfolgreich.' };
} catch (e: any) {
return { ok: false, message: e?.message || 'NAS-Verbindung fehlgeschlagen.' };
}
}
async function uploadToNas(cfg: NasCfg, remoteDir: string, filename: string, buf: Buffer): Promise<void> {
const dir = posixpath.join(cfg.basePath || '/', remoteDir);
const finalPath = posixpath.join(dir, filename);
const { Readable } = await import('node:stream');
if (cfg.protocol === 'sftp') {
const SftpClient = (await import('ssh2-sftp-client')).default;
const c = new SftpClient();
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 20000 });
try {
if (!(await c.exists(dir))) await c.mkdir(dir, true);
await c.put(buf, finalPath);
} finally { await c.end(); }
} else {
const { Client } = await import('basic-ftp');
const c = new Client(20000);
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
try {
await c.ensureDir(dir);
await c.uploadFrom(Readable.from(buf), finalPath);
} finally { c.close(); }
}
}
/** Spiegelt ein fertiges Item auf das NAS (Ordnerstruktur klarbild/JJJJ-MM/). */
export async function mirrorItemToNas(itemId: string): Promise<{ ok: boolean; message: string }> {
const cfg = await loadNasConfig();
if (!cfg) return { ok: false, message: 'NAS nicht konfiguriert.' };
const it = await one<any>('SELECT id, result_path, filename, created_at FROM items WHERE id=$1', [itemId]);
if (!it?.result_path) return { ok: false, message: 'Kein Ergebnis vorhanden.' };
await query(`UPDATE items SET nas_status='pending' WHERE id=$1`, [itemId]);
try {
const buf = await getObject(it.result_path);
const ym = new Date(it.created_at || Date.now()).toISOString().slice(0, 7); // JJJJ-MM
// Struktur: <Basisordner>/JJJJ-MM/<Datei> — der Basisordner ist frei wählbar.
await uploadToNas(cfg, ym, it.filename || `${it.id}.png`, buf);
await query(`UPDATE items SET nas_status='mirrored' WHERE id=$1`, [itemId]);
return { ok: true, message: 'Auf NAS gesichert.' };
} catch (e: any) {
console.error('[nas] mirror', itemId, 'fehlgeschlagen:', e?.message || e);
await query(`UPDATE items SET nas_status='failed' WHERE id=$1`, [itemId]);
return { ok: false, message: e?.message || 'NAS-Sicherung fehlgeschlagen.' };
}
}
+4 -2
View File
@@ -30,7 +30,8 @@ function friendlyFor(status: number): string {
export interface GenerateOpts { export interface GenerateOpts {
model: string; model: string;
prompt: string; prompt: string;
inputUrl?: string; // vorsignierte URL des Originals inputUrl?: string; // eine Vorlage (Data-URL) — Kurzform
inputUrls?: string[]; // mehrere Vorlagen (compose) — bis zu 14/16 je Modell
aspectRatio?: string; // "16:9", "3:4" … aspectRatio?: string; // "16:9", "3:4" …
resolution?: string; // "2K" | "4K" — NICHT zusammen mit expliziten Pixeln resolution?: string; // "2K" | "4K" — NICHT zusammen mit expliziten Pixeln
background?: 'transparent' | 'opaque'; background?: 'transparent' | 'opaque';
@@ -56,7 +57,8 @@ export async function generateImage(opts: GenerateOpts): Promise<GenerateResult>
n: 1, n: 1,
output_format: opts.outputFormat || 'png', output_format: opts.outputFormat || 'png',
}; };
if (opts.inputUrl) body.input_references = [{ type: 'image_url', image_url: { url: opts.inputUrl } }]; const refs = opts.inputUrls?.length ? opts.inputUrls : (opts.inputUrl ? [opts.inputUrl] : []);
if (refs.length) body.input_references = refs.map((url) => ({ type: 'image_url', image_url: { url } }));
if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio; if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio;
if (opts.resolution && !opts.aspectRatio) body.resolution = opts.resolution; // nie beides if (opts.resolution && !opts.aspectRatio) body.resolution = opts.resolution; // nie beides
if (opts.background) body.background = opts.background; if (opts.background) body.background = opts.background;
+196
View File
@@ -0,0 +1,196 @@
// Papier- und Bildformate für den Druckbogen (Modul „Druck", ohne KI).
// Alle Maße in Millimetern — mm ist die Leitwährung dieses Moduls, weil ein
// Druckbogen physisch ist. Pixel entstehen erst beim Rendern (mm → dpi).
export interface PaperSize { id: string; label: string; w: number; h: number; group: string }
/** Bogenformate (Hochformat-Konvention: kurze Seite × lange Seite). */
export const PAPERS: PaperSize[] = [
// DIN
{ id: 'A6', label: 'DIN A6', w: 105, h: 148, group: 'DIN' },
{ id: 'A5', label: 'DIN A5', w: 148, h: 210, group: 'DIN' },
{ id: 'A4', label: 'DIN A4', w: 210, h: 297, group: 'DIN' },
{ id: 'A3', label: 'DIN A3', w: 297, h: 420, group: 'DIN' },
{ id: 'A3plus', label: 'DIN A3+ (Super A3)', w: 329, h: 483, group: 'DIN' },
{ id: 'A2', label: 'DIN A2', w: 420, h: 594, group: 'DIN' },
{ id: 'A1', label: 'DIN A1', w: 594, h: 841, group: 'DIN' },
// Fotopapier (Zuschnitte, wie sie im Fachhandel liegen)
{ id: 'F9x13', label: 'Fotopapier 9 × 13 cm', w: 90, h: 130, group: 'Fotopapier' },
{ id: 'F10x15', label: 'Fotopapier 10 × 15 cm', w: 100, h: 150, group: 'Fotopapier' },
{ id: 'F13x18', label: 'Fotopapier 13 × 18 cm', w: 130, h: 180, group: 'Fotopapier' },
{ id: 'F15x20', label: 'Fotopapier 15 × 20 cm', w: 150, h: 200, group: 'Fotopapier' },
{ id: 'F20x30', label: 'Fotopapier 20 × 30 cm', w: 200, h: 300, group: 'Fotopapier' },
{ id: 'F10x10', label: 'Fotopapier 10 × 10 cm', w: 100, h: 100, group: 'Fotopapier' },
{ id: 'F13x13', label: 'Fotopapier 13 × 13 cm', w: 130, h: 130, group: 'Fotopapier' },
{ id: 'F4x6', label: 'Fotopapier 4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Fotopapier' },
{ id: 'F5x7', label: 'Fotopapier 5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Fotopapier' },
{ id: 'F8x10', label: 'Fotopapier 8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Fotopapier' },
// US
{ id: 'Letter', label: 'US Letter', w: 215.9, h: 279.4, group: 'US' },
{ id: 'Legal', label: 'US Legal', w: 215.9, h: 355.6, group: 'US' },
];
/** Bildformate (Endformat eines einzelnen Bildes auf dem Bogen). */
export interface PhotoSize { id: string; label: string; w: number; h: number; group: string }
export const PHOTO_SIZES: PhotoSize[] = [
// --- Passbild / Ausweis (Landesvorgaben) ---
{ id: 'P35x45', label: 'Passbild 35 × 45 mm (DE/EU, biometrisch)', w: 35, h: 45, group: 'Passbild' },
{ id: 'P33x48', label: 'Visum China 33 × 48 mm', w: 33, h: 48, group: 'Passbild' },
{ id: 'P50x50', label: 'Visum USA 51 × 51 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passbild' },
{ id: 'P50x70', label: 'Passbild Kanada 50 × 70 mm', w: 50, h: 70, group: 'Passbild' },
{ id: 'P45x35', label: 'Passbild quer 45 × 35 mm', w: 45, h: 35, group: 'Passbild' },
{ id: 'P25x35', label: 'Klein-Ausweis 25 × 35 mm', w: 25, h: 35, group: 'Passbild' },
// --- Kleinformate (Kita, Schule, Portemonnaie) ---
{ id: 'K20x30', label: '2 × 3 cm', w: 20, h: 30, group: 'Klein' },
{ id: 'K30x40', label: '3 × 4 cm', w: 30, h: 40, group: 'Klein' },
{ id: 'K35x50', label: '3,5 × 5 cm', w: 35, h: 50, group: 'Klein' },
{ id: 'K40x50', label: '4 × 5 cm', w: 40, h: 50, group: 'Klein' },
{ id: 'K45x60', label: '4,5 × 6 cm', w: 45, h: 60, group: 'Klein' },
{ id: 'K50x70', label: '5 × 7 cm', w: 50, h: 70, group: 'Klein' },
{ id: 'K60x80', label: '6 × 8 cm', w: 60, h: 80, group: 'Klein' },
{ id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Klein' },
{ id: 'K70x100', label: '7 × 10 cm', w: 70, h: 100, group: 'Klein' },
// --- Klassische Fotoformate ---
{ id: 'S9x13', label: '9 × 13 cm', w: 90, h: 130, group: 'Foto' },
{ id: 'S10x15', label: '10 × 15 cm', w: 100, h: 150, group: 'Foto' },
{ id: 'S11x15', label: '11 × 15 cm', w: 110, h: 150, group: 'Foto' },
{ id: 'S12x15', label: '12 × 15 cm', w: 120, h: 150, group: 'Foto' },
{ id: 'S13x18', label: '13 × 18 cm', w: 130, h: 180, group: 'Foto' },
{ id: 'S15x20', label: '15 × 20 cm', w: 150, h: 200, group: 'Foto' },
{ id: 'S15x21', label: '15 × 21 cm', w: 150, h: 210, group: 'Foto' },
{ id: 'S18x24', label: '18 × 24 cm', w: 180, h: 240, group: 'Foto' },
{ id: 'S20x25', label: '20 × 25 cm', w: 200, h: 250, group: 'Foto' },
{ id: 'S20x30', label: '20 × 30 cm', w: 200, h: 300, group: 'Foto' },
{ id: 'S24x30', label: '24 × 30 cm', w: 240, h: 300, group: 'Foto' },
{ id: 'S28x35', label: '28 × 35 cm', w: 280, h: 350, group: 'Foto' },
// --- Zollmaße (US-Fotolabore, Rahmen aus dem Ausland) ---
{ id: 'Z2_5x3_5', label: 'Wallet 2,5 × 3,5 in (64 × 89 mm)', w: 63.5, h: 88.9, group: 'Zoll (US)' },
{ id: 'Z3_5x5', label: '3,5 × 5 in (89 × 127 mm)', w: 88.9, h: 127, group: 'Zoll (US)' },
{ id: 'Z4x6', label: '4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Zoll (US)' },
{ id: 'Z5x7', label: '5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Zoll (US)' },
{ id: 'Z8x10', label: '8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Zoll (US)' },
{ id: 'Z11x14', label: '11 × 14 in (279 × 356 mm)', w: 279.4, h: 355.6, group: 'Zoll (US)' },
{ id: 'Z16x20', label: '16 × 20 in (406 × 508 mm)', w: 406.4, h: 508, group: 'Zoll (US)' },
{ id: 'Z24x36', label: '24 × 36 in (610 × 914 mm)', w: 609.6, h: 914.4, group: 'Zoll (US)' },
// --- Sofortbild-Nachbauten (Bildfläche, nicht Kartengröße) ---
{ id: 'I46x62', label: 'Instax mini · Bildfläche 46 × 62 mm', w: 46, h: 62, group: 'Sofortbild' },
{ id: 'I62x62', label: 'Instax Square · Bildfläche 62 × 62 mm', w: 62, h: 62, group: 'Sofortbild' },
{ id: 'I99x62', label: 'Instax Wide · Bildfläche 99 × 62 mm', w: 99, h: 62, group: 'Sofortbild' },
{ id: 'I79x79', label: 'Polaroid · Bildfläche 79 × 79 mm', w: 79, h: 79, group: 'Sofortbild' },
{ id: 'I88x107', label: 'Polaroid · ganze Karte 88 × 107 mm', w: 88, h: 107, group: 'Sofortbild' },
// --- Poster- und Rahmenformate ---
{ id: 'R30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Poster & Rahmen' },
{ id: 'R30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Poster & Rahmen' },
{ id: 'R40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Poster & Rahmen' },
{ id: 'R40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Poster & Rahmen' },
{ id: 'R45x60', label: '45 × 60 cm', w: 450, h: 600, group: 'Poster & Rahmen' },
{ id: 'R50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Poster & Rahmen' },
{ id: 'R60x80', label: '60 × 80 cm', w: 600, h: 800, group: 'Poster & Rahmen' },
{ id: 'R60x90', label: '60 × 90 cm', w: 600, h: 900, group: 'Poster & Rahmen' },
{ id: 'R70x100', label: '70 × 100 cm', w: 700, h: 1000, group: 'Poster & Rahmen' },
{ id: 'R80x120', label: '80 × 120 cm', w: 800, h: 1200, group: 'Poster & Rahmen' },
{ id: 'R100x140', label: '100 × 140 cm', w: 1000, h: 1400, group: 'Poster & Rahmen' },
// --- Quadrate ---
{ id: 'Q10x10', label: '10 × 10 cm', w: 100, h: 100, group: 'Quadrat' },
{ id: 'Q13x13', label: '13 × 13 cm', w: 130, h: 130, group: 'Quadrat' },
{ id: 'Q15x15', label: '15 × 15 cm', w: 150, h: 150, group: 'Quadrat' },
{ id: 'Q20x20', label: '20 × 20 cm', w: 200, h: 200, group: 'Quadrat' },
{ id: 'Q25x25', label: '25 × 25 cm', w: 250, h: 250, group: 'Quadrat' },
{ id: 'Q30x30', label: '30 × 30 cm', w: 300, h: 300, group: 'Quadrat' },
{ id: 'Q40x40', label: '40 × 40 cm', w: 400, h: 400, group: 'Quadrat' },
{ id: 'Q50x50', label: '50 × 50 cm', w: 500, h: 500, group: 'Quadrat' },
{ id: 'Q70x70', label: '70 × 70 cm', w: 700, h: 700, group: 'Quadrat' },
// --- DIN ---
{ id: 'DA7', label: 'DIN A7 (7,4 × 10,5 cm)', w: 74, h: 105, group: 'DIN' },
{ id: 'DA6', label: 'DIN A6 (10,5 × 14,8 cm)', w: 105, h: 148, group: 'DIN' },
{ id: 'DA5', label: 'DIN A5 (14,8 × 21 cm)', w: 148, h: 210, group: 'DIN' },
{ id: 'DA4', label: 'DIN A4 (21 × 29,7 cm)', w: 210, h: 297, group: 'DIN' },
{ id: 'DA3', label: 'DIN A3 (29,7 × 42 cm)', w: 297, h: 420, group: 'DIN' },
{ id: 'DA3P', label: 'DIN A3+ (32,9 × 48,3 cm)', w: 329, h: 483, group: 'DIN' },
{ id: 'DA2', label: 'DIN A2 (42 × 59,4 cm)', w: 420, h: 594, group: 'DIN' },
{ id: 'DA1', label: 'DIN A1 (59,4 × 84,1 cm)', w: 594, h: 841, group: 'DIN' },
{ id: 'DA0', label: 'DIN A0 (84,1 × 118,9 cm)', w: 841, h: 1189, group: 'DIN' },
// --- Karten & Drucksachen ---
{ id: 'C85x55', label: 'Visitenkarte 85 × 55 mm', w: 85, h: 55, group: 'Karten' },
{ id: 'C148x105', label: 'Postkarte 14,8 × 10,5 cm (A6 quer)', w: 148, h: 105, group: 'Karten' },
{ id: 'C99x210', label: 'DIN lang 9,9 × 21 cm', w: 99, h: 210, group: 'Karten' },
{ id: 'C210x99', label: 'DIN lang quer 21 × 9,9 cm', w: 210, h: 99, group: 'Karten' },
{ id: 'C54x86', label: 'Scheckkarte 85,6 × 54 mm', w: 85.6, h: 54, group: 'Karten' },
{ id: 'C70x100', label: 'Lesezeichen 7 × 21 cm', w: 70, h: 210, group: 'Karten' },
// --- Seitenverhältnisse als Druckmaß (auf 30 cm gerechnet) ---
{ id: 'W16x9', label: '16:9 „The Frame" (30 cm breit)', w: 300, h: 168.75, group: 'Seitenverhältnis' },
{ id: 'W9x16', label: '9:16 Hochformat (30 cm hoch)', w: 168.75, h: 300, group: 'Seitenverhältnis' },
{ id: 'W21x9', label: '21:9 Kino (30 cm breit)', w: 300, h: 128.57, group: 'Seitenverhältnis' },
{ id: 'W2x1', label: '2:1 Panorama (30 cm breit)', w: 300, h: 150, group: 'Seitenverhältnis' },
{ id: 'W3x2', label: '3:2 (30 cm breit)', w: 300, h: 200, group: 'Seitenverhältnis' },
{ id: 'W2x3', label: '2:3 (30 cm hoch)', w: 200, h: 300, group: 'Seitenverhältnis' },
{ id: 'W4x3', label: '4:3 (30 cm breit)', w: 300, h: 225, group: 'Seitenverhältnis' },
{ id: 'W3x4', label: '3:4 (30 cm hoch)', w: 225, h: 300, group: 'Seitenverhältnis' },
{ id: 'W5x4', label: '5:4 (30 cm breit)', w: 300, h: 240, group: 'Seitenverhältnis' },
{ id: 'W4x5', label: '4:5 Instagram (30 cm hoch)', w: 240, h: 300, group: 'Seitenverhältnis' },
{ id: 'W1x1', label: '1:1 quadratisch (30 cm)', w: 300, h: 300, group: 'Seitenverhältnis' },
];
export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null;
export const photoById = (id: string) => PHOTO_SIZES.find((p) => p.id === id) || null;
/**
* Freitext-Maß → mm. Bewusst großzügig, weil Till Maße so tippt, wie er sie sagt:
* „12x15" → 120 × 150 mm (cm ist die Standardeinheit)
* „12 × 15 cm" → 120 × 150 mm
* „35x45mm" → 35 × 45 mm
* „5" → 50 × 50 mm (quadratisch)
* „4:3 / 15" → Seitenverhältnis 4:3, längere Seite 15 cm → 150 × 112,5 mm
*/
export function parseSizeMm(input: string): { w: number; h: number } | null {
const t = (input || '').trim().toLowerCase().replace(/,/g, '.');
if (!t) return null;
// Seitenverhältnis mit Zielkante: „4:3 / 15" oder „4:3 15cm"
const ratio = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*(?:[/@ ]\s*(\d+(?:\.\d+)?)\s*(mm|cm)?)?$/.exec(t);
if (ratio && ratio[3]) {
// a:b wird als Breite:Höhe gelesen — „3:4/15" ist also hochkant, „4:3/15" quer.
const a = parseFloat(ratio[1]), b = parseFloat(ratio[2]);
const long = ratio[4] === 'mm' ? parseFloat(ratio[3]) : parseFloat(ratio[3]) * 10;
if (!a || !b || !long) return null;
return a >= b ? norm(long, long * (b / a)) : norm(long * (a / b), long);
}
const unit = /mm\s*$/.test(t) ? 1 : 10; // ohne Einheit: cm
const body = t.replace(/\s*(mm|cm)\s*$/, '').trim();
const wh = /^(\d+(?:\.\d+)?)\s*[x×*]\s*(\d+(?:\.\d+)?)$/.exec(body);
if (wh) return norm(parseFloat(wh[1]) * unit, parseFloat(wh[2]) * unit);
const sq = /^(\d+(?:\.\d+)?)$/.exec(body);
if (sq) { const n = parseFloat(sq[1]) * unit; return norm(n, n); }
return null;
}
function norm(w: number, h: number): { w: number; h: number } | null {
const r = (n: number) => Math.round(n * 100) / 100;
if (!(w > 0) || !(h > 0) || w > 2000 || h > 2000) return null;
return { w: r(w), h: r(h) };
}
export const mmToPx = (mm: number, dpi: number) => Math.round((mm / 25.4) * dpi);
export const mmToPt = (mm: number) => (mm / 25.4) * 72;
/** Hübsche Beschriftung eines Maßes — ohne die echten Nachkommastellen zu verlieren. */
export function labelMm(w: number, h: number): string {
const mm = (n: number) => de(Math.round(n * 10) / 10);
const cm = (n: number) => de(Math.round(n * 100) / 1000);
return w < 100 && h < 100 ? `${mm(w)} × ${mm(h)} mm` : `${cm(w)} × ${cm(h)} cm`;
}
const de = (n: number) => String(n).replace('.', ',');
+14
View File
@@ -22,6 +22,20 @@ export async function loadConfig(): Promise<PicdropCfg | null> {
}; };
} }
/** Zusätzliches Auslieferungsziel aus delivery_targets. */
export async function loadTargetConfig(id: string): Promise<PicdropCfg | null> {
const s = await one<any>(`SELECT protocol, host, port, username, password_enc, base_path
FROM delivery_targets WHERE id=$1`, [id]);
if (!s?.host || !s?.username || !s?.password_enc) return null;
let password = '';
try { password = decrypt(s.password_enc); } catch { return null; }
return {
host: s.host, protocol: (s.protocol || 'sftp'),
port: s.port || (s.protocol === 'ftps' ? 21 : 22),
user: s.username, password, basePath: s.base_path || '/',
};
}
/** Verbindung testen: verbinden + Basisordner listen. */ /** Verbindung testen: verbinden + Basisordner listen. */
export async function testConnection(cfg: PicdropCfg): Promise<{ ok: boolean; message: string }> { export async function testConnection(cfg: PicdropCfg): Promise<{ ok: boolean; message: string }> {
try { try {
+13 -2
View File
@@ -93,11 +93,22 @@ export function slugify(s: string): string {
const GENERIC = new Set(['', 'foto', 'photo', 'bild', 'image', 'img', 'telegram', 'unbenannt', 'klarbild', 'screenshot', 'pxl', 'whatsapp']); const GENERIC = new Set(['', 'foto', 'photo', 'bild', 'image', 'img', 'telegram', 'unbenannt', 'klarbild', 'screenshot', 'pxl', 'whatsapp']);
/** Motiv aus einem beliebigen (evtl. schon verarbeiteten) Namen herausschälen:
* Datumspräfixe, UUIDs und lange Hex-Ketten entfernen, damit keine Doppelungen entstehen. */
function cleanMotif(name: string | null): string {
let s = (name || '').replace(/\.[^.]+$/, '');
// Ohne \b arbeiten — Wortgrenzen greifen an Unterstrichen nicht (…_2026-07-24…).
s = s.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ' '); // UUID
s = s.replace(/\d{4}-\d{2}-\d{2}/g, ' '); // Datum (auch mehrfach)
s = s.replace(/[0-9a-f]{16,}/gi, ' '); // lange Hex-Ketten
return slugify(s);
}
/** Logischer Dateiname: JJJJ-MM-TT_<motiv>_<format>.<ext> /** Logischer Dateiname: JJJJ-MM-TT_<motiv>_<format>.<ext>
* z. B. 2026-07-23_lea_the-frame.png */ * z. B. 2026-07-23_lea_the-frame.png */
export function buildResultFilename(originalName: string | null, formatToken: string, ext = 'png'): string { export function buildResultFilename(originalName: string | null, formatToken: string, ext = 'png'): string {
const raw = slugify((originalName || '').replace(/\.[^.]+$/, '')); const raw = cleanMotif(originalName);
const motif = GENERIC.has(raw) ? 'klarbild' : raw; const motif = (!raw || GENERIC.has(raw)) ? 'klarbild' : raw;
const date = new Date().toISOString().slice(0, 10); const date = new Date().toISOString().slice(0, 10);
const fmt = slugify(formatToken) || 'original'; const fmt = slugify(formatToken) || 'original';
return `${date}_${motif}_${fmt}.${ext}`; return `${date}_${motif}_${fmt}.${ext}`;
+388
View File
@@ -0,0 +1,388 @@
// Reine Layout-Mathematik für den Druckbogen — mm, kein I/O, keine Abhängigkeiten.
// Wird identisch von der Vorschau im Browser und vom PDF-Renderer auf dem Server
// benutzt, damit „was ich sehe" und „was gedruckt wird" dieselbe Rechnung sind.
//
// Koordinaten: Ursprung oben links, x nach rechts, y nach unten (wie Bildschirm/SVG).
// Der PDF-Renderer spiegelt y, weil PDF von unten links zählt.
export interface PlaceSpec {
id: string; // verweist auf ein Bild
wMm: number; // Endformat (Trimmbox) Breite
hMm: number; // Endformat Höhe
count: number; // wie oft aufs Papier
allowRotate?: boolean;
}
export interface SheetSpec {
wMm: number;
hMm: number;
marginMm: number; // nicht bedruckbarer Rand des Druckers (Sicherheitsrand)
gapMm: number; // Abstand zwischen zwei Endformaten
bleedMm?: number; // Beschnittzugabe je Seite (Bild ragt darüber hinaus)
center?: boolean; // Block auf dem Bogen zentrieren (Default: true)
}
export interface Placement {
specId: string;
copy: number; // 1-basiert
x: number; y: number; // linke obere Ecke der Trimmbox
w: number; h: number; // Trimmbox
rotated: boolean; // Bild um 90° gedreht platziert
}
export interface Line { x1: number; y1: number; x2: number; y2: number }
export interface Page { placements: Placement[] }
export interface LayoutResult {
pages: Page[];
/** Was auf keinen Bogen passt (z. B. Bild größer als das Papier). */
unplaced: { specId: string; count: number; reason: string }[];
perSheet: number; // Stück auf dem ersten Bogen (Kennzahl für die UI)
}
export type MarkMode = 'none' | 'corner' | 'grid';
const EPS = 1e-6;
/** Effektiver Abstand: Beschnittzugaben zweier Nachbarn dürfen sich nicht überlappen. */
export function effectiveGap(sheet: SheetSpec): number {
return Math.max(sheet.gapMm || 0, 2 * (sheet.bleedMm || 0));
}
/**
* Packt die Endformate auf so wenige Bogen wie möglich.
*
* Zwei Wege, bewusst getrennt:
* 1. Alle Bilder gleich groß → exaktes Raster (Spalten × Zeilen). Perfekt
* ausgerichtet, damit durchgehende Schnittlinien wirklich durchgehen.
* 2. Gemischte Größen → MaxRects (Best-Short-Side-Fit) mit optionaler
* 90°-Drehung. Das ist der Fall „ein großes Bild plus acht Passbilder":
* die Kleinen wandern in die Restfläche neben dem Großen.
*
* Der Abstand wird als „aufgeblasenes" Rechteck mitgeführt (Bild + Abstand),
* deshalb stimmen die Zwischenräume überall, auch am Rand.
*/
export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
const gap = effectiveGap(sheet);
const bleed = sheet.bleedMm || 0;
const inset = (sheet.marginMm || 0) + bleed;
const availW = sheet.wMm - 2 * inset;
const availH = sheet.hMm - 2 * inset;
const unplaced: LayoutResult['unplaced'] = [];
const units: { specId: string; copy: number; w: number; h: number; rot: boolean }[] = [];
for (const s of specs) {
const n = Math.max(0, Math.floor(s.count || 0));
if (!n) continue;
const fitsPlain = s.wMm <= availW + EPS && s.hMm <= availH + EPS;
const fitsRot = s.allowRotate !== false && s.hMm <= availW + EPS && s.wMm <= availH + EPS;
if (!fitsPlain && !fitsRot) {
unplaced.push({ specId: s.id, count: n, reason: 'größer als die Nutzfläche des Bogens' });
continue;
}
for (let c = 1; c <= n; c++) units.push({ specId: s.id, copy: c, w: s.wMm, h: s.hMm, rot: false });
}
if (!units.length) return { pages: [], unplaced, perSheet: 0 };
const uniform = units.every((u) => Math.abs(u.w - units[0].w) < EPS && Math.abs(u.h - units[0].h) < EPS);
let pages: Page[];
if (uniform) {
pages = gridPack(units, specs, availW, availH, gap);
} else {
// Ausrichtung je Bildformat festlegen (nicht je Exemplar) — sonst liegen
// gleiche Bilder wild durcheinander auf dem Bogen. Alle sinnvollen
// Kombinationen durchprobieren und die beste nehmen.
const ids = [...new Set(units.map((u) => u.specId))];
const rotatable = ids.filter((id) => {
const sp = specs.find((s) => s.id === id)!;
return sp.allowRotate !== false && Math.abs(sp.wMm - sp.hMm) > EPS;
});
const tooBig = ids.length > 5 || units.length > 150;
// Bei sehr vielen Formaten/Stücken nicht alle Kombinationen durchprobieren —
// aber jedem Format trotzdem eine Ausrichtung geben, in der es überhaupt passt.
const variants: Record<string, boolean>[] = tooBig
? [Object.fromEntries(ids.map((id) => {
const sp = specs.find((x) => x.id === id)!;
const plainFits = sp.wMm <= availW + EPS && sp.hMm <= availH + EPS;
return [id, !plainFits && sp.allowRotate !== false];
}))]
: combos(rotatable).map((set) => Object.fromEntries(ids.map((id) => [id, set.has(id)])));
let best: Page[] | null = null, bestScore = -Infinity;
for (const [vi, rotMap] of variants.entries()) {
const oriented = units.map((u) => {
const r = !!rotMap[u.specId];
const sp = specs.find((s) => s.id === u.specId)!;
// Nur Ausrichtungen zulassen, die überhaupt auf den Bogen passen.
const w = r ? sp.hMm : sp.wMm, h = r ? sp.wMm : sp.hMm;
return { ...u, w, h, rot: r };
});
if (oriented.some((u) => u.w > availW + EPS || u.h > availH + EPS)) continue;
const cand = maxRectsPack(oriented, availW, availH, gap);
const placed = cand.reduce((n, p) => n + p.placements.length, 0);
if (placed < units.length) continue;
// Wenige Bogen zählt am meisten, dann volle erste Seite, dann „so wenig
// gedreht wie möglich" (Tills Wunschausrichtung bleibt eher erhalten).
const score = -cand.length * 1000 + (cand[0]?.placements.length || 0) - 0.01 * vi
- 0.1 * Object.values(rotMap).filter(Boolean).length;
if (score > bestScore) { bestScore = score; best = cand; }
}
pages = best || maxRectsPack(units.map((u) => ({ ...u, rot: false })), availW, availH, gap);
// Fehlende Stücke je Bildformat zählen — nicht pauschal dem ersten zuschreiben.
const placedKeys = new Set(pages.flatMap((pg) => pg.placements.map((pl) => `${pl.specId}#${pl.copy}`)));
const missing = new Map<string, number>();
for (const u of units) if (!placedKeys.has(`${u.specId}#${u.copy}`)) missing.set(u.specId, (missing.get(u.specId) || 0) + 1);
for (const [id, n] of missing) unplaced.push({ specId: id, count: n, reason: 'kein Platz auf dem Bogen' });
}
// Auf dem Bogen ausrichten: Blockmitte oder linke obere Ecke.
for (const pg of pages) {
let offX = inset, offY = inset;
if (sheet.center !== false && pg.placements.length) {
const minX = Math.min(...pg.placements.map((p) => p.x));
const maxX = Math.max(...pg.placements.map((p) => p.x + p.w));
const minY = Math.min(...pg.placements.map((p) => p.y));
const maxY = Math.max(...pg.placements.map((p) => p.y + p.h));
offX = inset + (availW - (maxX - minX)) / 2 - minX;
offY = inset + (availH - (maxY - minY)) / 2 - minY;
}
for (const p of pg.placements) { p.x = round(p.x + offX); p.y = round(p.y + offY); }
}
return { pages, unplaced, perSheet: pages[0]?.placements.length || 0 };
}
/** Gleich große Bilder: sauberes Raster, Zeile für Zeile. */
function gridPack(units: { specId: string; copy: number; w: number; h: number }[], specs: PlaceSpec[],
availW: number, availH: number, gap: number): Page[] {
const spec = specs.find((s) => s.id === units[0].specId);
let { w, h } = units[0];
let rot = false;
const fit = (a: number, b: number) => ({
cols: Math.max(0, Math.floor((availW + gap + EPS) / (a + gap))),
rows: Math.max(0, Math.floor((availH + gap + EPS) / (b + gap))),
});
// Die vom Nutzer gewählte Ausrichtung hat Vorrang. Gedreht wird nur, wenn das
// die Zahl der Bogen tatsächlich senkt (oder ungedreht gar nichts passt).
let best = fit(w, h);
if (spec?.allowRotate !== false && Math.abs(w - h) > EPS) {
const alt = fit(h, w);
const plainN = best.cols * best.rows, altN = alt.cols * alt.rows;
const pages = (n: number) => (n > 0 ? Math.ceil(units.length / n) : Infinity);
if (pages(altN) < pages(plainN)) { best = alt; [w, h] = [h, w]; rot = true; }
}
const perPage = best.cols * best.rows;
if (!perPage) return [];
const pages: Page[] = [];
for (let i = 0; i < units.length; i += perPage) {
const chunk = units.slice(i, i + perPage);
const placements: Placement[] = chunk.map((u, k) => {
const c = k % best.cols, r = Math.floor(k / best.cols);
return { specId: u.specId, copy: u.copy, x: round(c * (w + gap)), y: round(r * (h + gap)), w, h, rotated: rot };
});
pages.push({ placements });
}
return pages;
}
interface FreeRect { x: number; y: number; w: number; h: number }
/** Gemischte Größen: MaxRects mit Best-Short-Side-Fit, Ausrichtung schon festgelegt. */
function maxRectsPack(units: { specId: string; copy: number; w: number; h: number; rot: boolean }[],
availW: number, availH: number, gap: number): Page[] {
// Groß zuerst — sonst blockieren Kleinteile die guten Flächen.
const todo = [...units].sort((a, b) =>
(b.w * b.h) - (a.w * a.h) || Math.max(b.w, b.h) - Math.max(a.w, a.h) ||
a.specId.localeCompare(b.specId) || a.copy - b.copy);
const W = availW + gap, H = availH + gap; // aufgeblasene Fläche
const pages: Page[] = [];
// Notbremse gegen entartete Eingaben (viele Formate × viele Stück): der
// Packer läuft synchron im Serverprozess und darf ihn nicht blockieren.
let steps = 0;
const MAX_STEPS = 2_000_000;
while (todo.length) {
let free: FreeRect[] = [{ x: 0, y: 0, w: W, h: H }];
const placements: Placement[] = [];
for (;;) {
let bestIdx = -1, bestScore = Infinity, bestAlt = Infinity;
let bestRect: FreeRect | null = null;
for (let i = 0; i < todo.length; i++) {
const u = todo[i];
const cw = u.w + gap, ch = u.h + gap;
if ((steps += free.length) > MAX_STEPS) break;
for (const r of free) {
if (cw > r.w + EPS || ch > r.h + EPS) continue;
const short = Math.min(r.w - cw, r.h - ch);
// Bei Gleichstand die am weitesten oben/links liegende Fläche nehmen —
// das ergibt ein ruhiges, lesbares Bogenbild statt verstreuter Motive.
const pos = r.y * 10000 + r.x;
if (short < bestScore - EPS || (Math.abs(short - bestScore) < EPS && pos < bestAlt - EPS)) {
bestScore = short; bestAlt = pos; bestIdx = i; bestRect = r;
}
}
}
if (bestIdx < 0 || !bestRect || steps > MAX_STEPS) break;
const u = todo.splice(bestIdx, 1)[0];
placements.push({ specId: u.specId, copy: u.copy, x: round(bestRect.x), y: round(bestRect.y), w: u.w, h: u.h, rotated: u.rot });
free = splitFree(free, { x: bestRect.x, y: bestRect.y, w: u.w + gap, h: u.h + gap });
}
if (!placements.length) break; // nichts platzierbar → Abbruch
placements.sort((a, b) => a.y - b.y || a.x - b.x);
pages.push({ placements });
if (pages.length > 200 || steps > MAX_STEPS) break; // Notbremse
}
return pages;
}
/** Alle Teilmengen (Ausrichtungs-Kombinationen), stabil sortiert. */
function combos(ids: string[]): Set<string>[] {
const out: Set<string>[] = [];
const n = Math.min(ids.length, 8);
for (let m = 0; m < (1 << n); m++) {
const s = new Set<string>();
for (let i = 0; i < n; i++) if (m & (1 << i)) s.add(ids[i]);
out.push(s);
}
// Erst „nichts gedreht", dann aufsteigend nach Anzahl Drehungen.
return out.sort((a, b) => a.size - b.size);
}
function splitFree(free: FreeRect[], used: FreeRect): FreeRect[] {
const out: FreeRect[] = [];
for (const r of free) {
const overlap = used.x < r.x + r.w - EPS && used.x + used.w > r.x + EPS
&& used.y < r.y + r.h - EPS && used.y + used.h > r.y + EPS;
if (!overlap) { out.push(r); continue; }
if (used.x > r.x + EPS) out.push({ x: r.x, y: r.y, w: used.x - r.x, h: r.h });
if (used.x + used.w < r.x + r.w - EPS) out.push({ x: used.x + used.w, y: r.y, w: r.x + r.w - (used.x + used.w), h: r.h });
if (used.y > r.y + EPS) out.push({ x: r.x, y: r.y, w: r.w, h: used.y - r.y });
if (used.y + used.h < r.y + r.h - EPS) out.push({ x: r.x, y: used.y + used.h, w: r.w, h: r.y + r.h - (used.y + used.h) });
}
// In anderen enthaltene Rechtecke wegwerfen (sonst wächst die Liste ins Unendliche).
return out.filter((a, i) => a.w > EPS && a.h > EPS && !out.some((b, j) =>
j !== i && b.x <= a.x + EPS && b.y <= a.y + EPS &&
b.x + b.w >= a.x + a.w - EPS && b.y + b.h >= a.y + a.h - EPS &&
(b.w > a.w + EPS || b.h > a.h + EPS || j < i)));
}
/** Wie viele Endformate passen maximal auf einen Bogen? (Kennzahl für die UI.) */
export function capacity(spec: Omit<PlaceSpec, 'count'>, sheet: SheetSpec): number {
// Direkt rechnen statt zu packen — sonst deckelt die Probe die Antwort.
const gap = effectiveGap(sheet);
const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0);
const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset;
const grid = (w: number, h: number) =>
Math.max(0, Math.floor((availW + gap + EPS) / (w + gap))) *
Math.max(0, Math.floor((availH + gap + EPS) / (h + gap)));
const plain = grid(spec.wMm, spec.hMm);
const rot = spec.allowRotate !== false ? grid(spec.hMm, spec.wMm) : 0;
return Math.max(plain, rot);
}
export interface MarkOptions {
mode: MarkMode;
lengthMm?: number; // Länge einer Marke (Norm: 410 mm)
offsetMm?: number; // Abstand vom Endformat nach außen
bleedMm?: number;
}
/**
* Schnittmarken als Liniensegmente.
* - `corner`: vier Eckenpaare je Bild, außerhalb des Endformats (Adobe-/Druckerei-Konvention).
* - `grid`: durchgehende Linien über den ganzen Bogen an jeder Schnittkante — für
* das Schneidelineal/die Schlagschere, wenn viele gleiche Bilder drauf sind.
*/
export function cutMarks(page: Page, sheet: SheetSpec, opt: MarkOptions): Line[] {
if (opt.mode === 'none') return [];
const len = opt.lengthMm ?? 4;
// Der Versatz muss den gedruckten Beschnitt überspringen — sonst landet die
// Marke auf dem Motiv. Ein zu kleiner Wunschwert wird deshalb angehoben.
const off = Math.max(opt.offsetMm ?? 0, (opt.bleedMm ?? 0) + 1, 2);
const lines: Line[] = [];
if (opt.mode === 'corner') {
for (const p of page.placements) {
const L = p.x, R = p.x + p.w, T = p.y, B = p.y + p.h;
// je Ecke: eine waagerechte und eine senkrechte Marke, nach außen weisend
for (const [x, y, sx, sy] of [[L, T, -1, -1], [R, T, 1, -1], [L, B, -1, 1], [R, B, 1, 1]] as const) {
lines.push({ x1: x + sx * off, y1: y, x2: x + sx * (off + len), y2: y });
lines.push({ x1: x, y1: y + sy * off, x2: x, y2: y + sy * (off + len) });
}
}
return clip(lines, sheet);
}
// grid: Schnittkanten über den ganzen Bogen ziehen — aber nur dort, wo kein
// anderes Bild im Weg ist. Sonst liefe die Linie quer durchs Nachbarmotiv.
const xs = uniq(page.placements.flatMap((p) => [p.x, p.x + p.w]));
const ys = uniq(page.placements.flatMap((p) => [p.y, p.y + p.h]));
for (const x of xs) {
const blocked = page.placements
.filter((p) => p.x + EPS < x && x < p.x + p.w - EPS)
.map((p) => [p.y, p.y + p.h] as [number, number]);
for (const [a, b] of complement(blocked, 0, sheet.hMm)) lines.push({ x1: x, y1: a, x2: x, y2: b });
}
for (const y of ys) {
const blocked = page.placements
.filter((p) => p.y + EPS < y && y < p.y + p.h - EPS)
.map((p) => [p.x, p.x + p.w] as [number, number]);
for (const [a, b] of complement(blocked, 0, sheet.wMm)) lines.push({ x1: a, y1: y, x2: b, y2: y });
}
return lines.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2);
}
const uniq = (ns: number[]) => [...new Set(ns.map(round))].sort((a, b) => a - b);
/** Freie Abschnitte in [from,to] nach Abzug der belegten Intervalle. */
function complement(blocked: [number, number][], from: number, to: number): [number, number][] {
const sorted = [...blocked].sort((a, b) => a[0] - b[0]);
const out: [number, number][] = [];
let cur = from;
for (const [a, b] of sorted) {
if (a > cur) out.push([cur, Math.min(a, to)]);
cur = Math.max(cur, b);
if (cur >= to) break;
}
if (cur < to) out.push([cur, to]);
return out.filter(([a, b]) => b - a > 0.2);
}
/**
* Kleinster Abstand, bei dem die gewählten Marken noch sinnvoll sind:
* Eckmarken zweier Nachbarn brauchen je (Versatz + Länge) Platz, Beschnittzugaben
* dürfen sich nicht überlappen.
*/
export function recommendedGap(mode: MarkMode, bleedMm = 0, lengthMm = 4, offsetMm?: number): number {
const off = offsetMm ?? Math.max(2, bleedMm + 1);
const base = 2 * bleedMm;
if (mode === 'corner') return Math.max(base, 2 * (off + lengthMm));
if (mode === 'grid') return Math.max(base, 0);
return base;
}
const round = (n: number) => Math.round(n * 1000) / 1000;
/** Marken, die über den Bogenrand hinausragen würden, abschneiden bzw. verwerfen. */
function clip(lines: Line[], sheet: SheetSpec): Line[] {
const cx = (v: number) => Math.min(sheet.wMm, Math.max(0, v));
const cy = (v: number) => Math.min(sheet.hMm, Math.max(0, v));
return lines
.map((l) => ({ x1: cx(l.x1), y1: cy(l.y1), x2: cx(l.x2), y2: cy(l.y2) }))
.filter((l) => Math.abs(l.x2 - l.x1) > 0.2 || Math.abs(l.y2 - l.y1) > 0.2);
}
/** Reicht die Quellauflösung für das Endformat bei gewünschter dpi? */
export function dpiCheck(srcPx: number, mm: number, wantDpi: number): { dpi: number; ok: boolean } {
const dpi = mm > 0 ? (srcPx / (mm / 25.4)) : 0;
return { dpi: Math.round(dpi), ok: dpi >= wantDpi * 0.9 };
}
+124
View File
@@ -0,0 +1,124 @@
// Übergroße Bilder: was tun, wenn das Endmaß größer ist als der Bogen?
// 20 × 30 cm auf DIN-A4-Fotopapier ist der Alltagsfall — es fehlen genau 3 mm.
// Reine Mathematik, keine Abhängigkeiten (wie printlayout.ts), damit Browser und
// Server dieselbe Rechnung benutzen.
import type { Placement, SheetSpec } from './printlayout.ts';
const EPS = 1e-6;
const round = (n: number) => Math.round(n * 1000) / 1000;
export type Oversize =
| 'fit' // nichts tun, als „passt nicht" melden (Standard)
| 'overflow' // mittig aufs Blatt legen, der Überstand wird beim Drucken abgeschnitten
| 'tile'; // auf mehrere Blätter verteilen (Posterdruck zum Zusammenkleben)
/** Passt das Endmaß auf die Nutzfläche des Bogens — notfalls gedreht? */
export function fitsOnSheet(wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true): boolean {
const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0);
const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset;
const plain = wMm <= availW + EPS && hMm <= availH + EPS;
const rot = allowRotate && hMm <= availW + EPS && wMm <= availH + EPS;
return plain || rot;
}
export interface OverflowInfo {
/** Platzierung auf dem Bogen — darf negativ sein, das Bild ragt dann hinaus. */
placement: Placement;
rotated: boolean;
/** Wie viel je Seite verloren geht (mm), nach dem Drehen gerechnet. */
lostLeft: number; lostRight: number; lostTop: number; lostBottom: number;
lostMm: number;
}
/**
* Ein zu großes Bild mittig aufs **ganze Blatt** legen — bewusst nicht auf die
* Nutzfläche, sonst verschenkt man den Rand, den ein randloser Drucker sehr wohl
* bedrucken kann. Was über den Bogen ragt, fehlt hinterher; wie viel, steht in `lost*`.
*/
export function overflowPlacement(
specId: string, copy: number, wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true,
): OverflowInfo {
const loss = (w: number, h: number) => Math.max(0, w - sheet.wMm) + Math.max(0, h - sheet.hMm);
const rotated = !!allowRotate && loss(hMm, wMm) < loss(wMm, hMm) - EPS;
const w = rotated ? hMm : wMm, h = rotated ? wMm : hMm;
const x = round((sheet.wMm - w) / 2), y = round((sheet.hMm - h) / 2);
const lostLeft = round(Math.max(0, -x)), lostTop = round(Math.max(0, -y));
const lostRight = round(Math.max(0, x + w - sheet.wMm));
const lostBottom = round(Math.max(0, y + h - sheet.hMm));
return {
placement: { specId, copy, x, y, w, h, rotated },
rotated, lostLeft, lostRight, lostTop, lostBottom,
lostMm: round(lostLeft + lostRight + lostTop + lostBottom),
};
}
/**
* Beim Posterdruck steht auf jedem Blatt „Blatt 2/4 · Reihe 1, Spalte 2" — das ist
* die Montageanleitung und wird deshalb immer gedruckt (8 pt ab 3 mm Oberkante,
* braucht rund 6 mm). Ist der Papierrand knapper, muss die Kachel so weit nach
* unten rücken; damit sie unten nicht herausragt, wird der Bogen entsprechend
* niedriger gerechnet. Browser und Server benutzen dieselbe Funktion, sonst zeigte
* die Vorschau eine andere Blattzahl als das fertige PDF.
*/
export const NOTE_MM = 6;
export function noteInset(sheet: SheetSpec): number {
return Math.max(0, NOTE_MM - (sheet.marginMm || 0));
}
export function tileSheet(sheet: SheetSpec): SheetSpec {
const n = noteInset(sheet);
return n ? { ...sheet, hMm: sheet.hMm - n } : sheet;
}
export interface Tile {
col: number; row: number; // 0-basiert
xMm: number; yMm: number; // Ausschnitt im Zielbild, ab linker oberer Ecke
wMm: number; hMm: number;
glueRight: boolean; glueBottom: boolean; // dort schließt eine Nachbarkachel an
}
export interface TilePlan { cols: number; rows: number; tiles: Tile[]; overlapMm: number }
/**
* Posterdruck: ein Bild auf mehrere Blätter verteilen.
* Die Kacheln überlappen sich um `overlapMm` — dieser Streifen ist der Klebefalz.
* Er wird auf beiden Nachbarblättern gedruckt; beim Zusammensetzen legt man das
* eine Blatt entlang der gestrichelten Linie auf das andere.
*/
export function tilePlan(wMm: number, hMm: number, sheet: SheetSpec, overlapMm = 10): TilePlan {
const inset = sheet.marginMm || 0;
const availW = Math.max(10, sheet.wMm - 2 * inset);
const availH = Math.max(10, sheet.hMm - 2 * inset);
const o = Math.max(0, Math.min(overlapMm, Math.min(availW, availH) / 3));
const stepX = Math.max(1, availW - o), stepY = Math.max(1, availH - o);
const cols = Math.max(1, Math.ceil((wMm - o) / stepX));
const rows = Math.max(1, Math.ceil((hMm - o) / stepY));
const tiles: Tile[] = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = c * stepX, y = r * stepY;
const w = Math.min(availW, wMm - x), h = Math.min(availH, hMm - y);
if (w <= 0.5 || h <= 0.5) continue;
tiles.push({
col: c, row: r, xMm: round(x), yMm: round(y), wMm: round(w), hMm: round(h),
glueRight: c < cols - 1, glueBottom: r < rows - 1,
});
}
}
return { cols, rows, tiles, overlapMm: round(o) };
}
/** Ausschnitt einer Kachel, verschachtelt in den Zuschnitt des Nutzers. */
export function tileCrop(
crop: { x: number; y: number; w: number; h: number },
tile: Tile, wMm: number, hMm: number,
): { x: number; y: number; w: number; h: number } {
return {
x: crop.x + (tile.xMm / wMm) * crop.w,
y: crop.y + (tile.yMm / hMm) * crop.h,
w: (tile.wMm / wMm) * crop.w,
h: (tile.hMm / hMm) * crop.h,
};
}
+264
View File
@@ -0,0 +1,264 @@
// Rendern des Druckbogens: Zuschnitt (sharp) + PDF-Aufbau (pdf-lib).
// Bewusst ohne KI — reine Geometrie und Skalierung, damit das Ergebnis
// vorhersagbar und wiederholbar ist.
import sharp from 'sharp';
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
import { mmToPt, mmToPx } from './paper.ts';
import type { Line, Page, SheetSpec } from './printlayout.ts';
/** Zuschnitt relativ zur Quelle (0..1) — auflösungsunabhängig speicherbar. */
export interface CropRel { x: number; y: number; w: number; h: number }
export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 };
/**
* Was passiert, wenn das Bild nicht dem Zielverhältnis entspricht?
* - `cover` — zuschneiden, bis das Format ausgefüllt ist (kein Rand, es fehlt etwas).
* - `contain` — das ganze Bild einpassen, der Rest wird zur Randfarbe (nichts fehlt).
* Verzerrt wird nie.
*/
export type FitMode = 'cover' | 'contain';
/** Größter mittiger Ausschnitt im Zielverhältnis — füllt aus, schneidet ab. */
export function coverCrop(natW: number, natH: number, aspect: number): CropRel {
const imgA = natW / natH;
const w = imgA > aspect ? aspect / imgA : 1;
const h = imgA > aspect ? 1 : imgA / aspect;
return { x: (1 - w) / 2, y: (1 - h) / 2, w, h };
}
/**
* Kleinster Ausschnitt im Zielverhältnis, der das **ganze** Bild enthält.
* Ragt bewusst über den Bildrand hinaus (w bzw. h > 1) — dort entsteht der Rand.
*/
export function containCrop(natW: number, natH: number, aspect: number): CropRel {
const imgA = natW / natH;
const w = imgA > aspect ? 1 : aspect / imgA;
const h = imgA > aspect ? imgA / aspect : 1;
return { x: (1 - w) / 2, y: (1 - h) / 2, w, h };
}
/**
* Bringt einen Bildausschnitt exakt auf ein physisches Maß.
* `bleedMm` vergrößert das gerenderte Feld nach außen; die Trimmbox bleibt
* exakt der gewählte Ausschnitt. Fehlende Randpixel werden aus der Kante
* fortgeschrieben (extendWith 'copy'), statt das Motiv zu stauchen.
*/
export async function renderCell(
input: Buffer,
crop: CropRel | null,
wMm: number,
hMm: number,
dpi: number,
opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string; fit?: FitMode } = {},
): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> {
const bleed = Math.max(0, opt.bleedMm || 0);
const fit: FitMode = opt.fit === 'contain' ? 'contain' : 'cover';
const bg = /^#[0-9a-fA-F]{6}$/.test(opt.background || '') ? opt.background! : '#ffffff';
// EXIF-Ausrichtung anwenden, BEVOR gerechnet wird. Handyfotos tragen die
// Drehung nur als Metadatum; Browser und iPhone zeigen sie gedreht, sharp
// rechnet ohne diesen Schritt auf dem ungedrehten Raster — dann kommt das
// Bild quer und mit falschem Ausschnitt aus dem Drucker.
const meta0 = await sharp(input, { failOn: 'none' }).metadata();
const src = (meta0.orientation ?? 1) > 1
? await sharp(input, { failOn: 'none' }).rotate().toBuffer()
: input;
const meta = (meta0.orientation ?? 1) > 1 ? await sharp(src, { failOn: 'none' }).metadata() : meta0;
const nw = meta.width || 0, nh = meta.height || 0;
if (!nw || !nh) throw new Error('Bildmaße unbekannt.');
const c = crop ? normCrop(crop, fit) : (fit === 'contain'
? containCrop(nw, nh, wMm / hMm)
: coverCrop(nw, nh, wMm / hMm));
// Beschnittzugabe: Ausschnitt mittig erweitern, damit die Trimmbox exakt
// der gerahmte Bereich bleibt.
const fx = (wMm + 2 * bleed) / wMm;
const fy = (hMm + 2 * bleed) / hMm;
const cw = c.w * fx, ch = c.h * fy;
const cx = c.x - (cw - c.w) / 2, cy = c.y - (ch - c.h) / 2;
const targetW = mmToPx(wMm + 2 * bleed, dpi);
const targetH = mmToPx(hMm + 2 * bleed, dpi);
if (targetW * targetH > MAX_TARGET_PX)
throw new Error(`Zielbild zu groß (${targetW}×${targetH} px). Bitte Maß oder Auflösung verringern.`);
// Sichtbarer Teil des Ausschnitts (der Rest wird gefüllt, nicht gerendert).
const vx0 = Math.max(cx, 0), vy0 = Math.max(cy, 0);
const vx1 = Math.min(cx + cw, 1), vy1 = Math.min(cy + ch, 1);
if (vx1 - vx0 <= 0 || vy1 - vy0 <= 0) throw new Error('Ausschnitt liegt außerhalb des Bildes.');
const L = clampInt(Math.round(vx0 * nw), 0, nw - 1);
const T = clampInt(Math.round(vy0 * nh), 0, nh - 1);
const W = clampInt(Math.round((vx1 - vx0) * nw), 1, nw - L);
const H = clampInt(Math.round((vy1 - vy0) * nh), 1, nh - T);
// Abbildung Ausschnitt → Zielbild (Maßstab in px je Ausschnittsanteil).
const sx = targetW / (cw * nw), sy = targetH / (ch * nh);
let dx = clampInt(Math.round((vx0 - cx) * nw * sx), 0, targetW - 1);
let dy = clampInt(Math.round((vy0 - cy) * nh * sy), 0, targetH - 1);
const dw = clampInt(Math.round(W * sx), 1, targetW - dx);
const dh = clampInt(Math.round(H * sy), 1, targetH - dy);
const right = targetW - dx - dw, bottom = targetH - dy - dh;
// Eine einzige sharp-Kette: extract → resize → extend. Diese Reihenfolge ist
// sharps interne Reihenfolge, deshalb stimmen die Zahlen. Wichtig: erst nach
// dem Verkleinern auffüllen, sonst wächst das Zwischenbild ins Unermessliche.
let img = sharp(src, { failOn: 'none' })
.extract({ left: L, top: T, width: W, height: H })
.resize(dw, dh, { fit: 'fill' });
if (dx || dy || right || bottom) {
img = img.extend(fit === 'contain'
? { top: dy, left: dx, bottom, right, background: bg }
: { top: dy, left: dx, bottom, right, extendWith: 'copy' });
}
const hasAlpha = !!meta.hasAlpha;
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg });
const encode = (pipe: sharp.Sharp) => (ext === 'png'
? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
: pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }));
// Drehen erst im zweiten Durchgang: sharp würde eine Drehung sonst vor dem
// Auffüllen anwenden und die Ränder auf die falschen Seiten legen.
let out = await (opt.rotate ? img.png({ compressionLevel: 0 }) : encode(img))
.toBuffer({ resolveWithObject: true });
if (opt.rotate) out = await encode(sharp(out.data, { failOn: 'none' }).rotate(90))
.toBuffer({ resolveWithObject: true });
return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] };
}
/** Obergrenze fürs Zielbild — schützt vor Speicherexplosion durch extreme Maß/dpi-Kombinationen. */
const MAX_TARGET_PX = 300_000_000;
const clampInt = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n | 0));
function normCrop(c: CropRel | null, fit: FitMode = 'cover'): CropRel {
if (!c) return FULL_CROP;
const num = (v: number, d = 0) => (Number.isFinite(v) ? v : d);
// Beim Einpassen darf der Ausschnitt größer als das Bild sein (dort entsteht der Rand),
// beim Zuschneiden muss er innerhalb des Bildes liegen.
const maxSide = fit === 'contain' ? 8 : 1;
const w = Math.min(maxSide, Math.max(0.001, num(c.w, 1)));
const h = Math.min(maxSide, Math.max(0.001, num(c.h, 1)));
if (fit === 'contain') {
// Nur verhindern, dass das Bild komplett aus dem Ausschnitt herausgeschoben wird.
const x = Math.min(1 - 0.05 * w, Math.max(-w + 0.05 * w, num(c.x)));
const y = Math.min(1 - 0.05 * h, Math.max(-h + 0.05 * h, num(c.y)));
return { x, y, w, h };
}
const x = Math.min(1 - w, Math.max(0, num(c.x)));
const y = Math.min(1 - h, Math.max(0, num(c.y)));
return { x, y, w, h };
}
export interface SheetCellImage { bytes: Buffer; ext: 'jpg' | 'png' }
/** Wo die Fußzeile steht. Vorne die Kante, hinten die Ausrichtung. */
export type FooterPos =
| 'unten-links' | 'unten-mitte' | 'unten-rechts'
| 'oben-links' | 'oben-mitte' | 'oben-rechts';
export interface FooterSpec { text: string; position?: FooterPos; sizePt?: number }
/** Zusätzliches je Seite — für den Posterdruck: Blattnummer und Klebefalz. */
export interface PageExtras { note?: string | null; dashed?: Line[] }
export interface SheetPdfInput {
sheet: SheetSpec;
pages: Page[];
marksPerPage: Line[][];
/** specId → fertig gerendertes Bild (inkl. Beschnittzugabe, ggf. gedreht). */
images: Record<string, SheetCellImage>;
title?: string;
footer?: FooterSpec | null;
extrasPerPage?: PageExtras[];
markWidthPt?: number;
}
/**
* Baut das Druck-PDF. Seitengröße = Bogengröße in mm, 1:1 — beim Drucken
* unbedingt „Tatsächliche Größe / 100 %" wählen, nicht „an Seite anpassen".
*/
export async function buildSheetPdf(input: SheetPdfInput): Promise<Uint8Array> {
const { sheet, pages, marksPerPage, images } = input;
const pdf = await PDFDocument.create();
pdf.setTitle(input.title || 'Klarbild Druckbogen');
pdf.setProducer('Klarbild');
pdf.setCreator('Klarbild — Druckbogen');
const embedded: Record<string, any> = {};
for (const [id, img] of Object.entries(images)) {
embedded[id] = img.ext === 'png' ? await pdf.embedPng(img.bytes) : await pdf.embedJpg(img.bytes);
}
const needsFont = !!input.footer || (input.extrasPerPage || []).some((e) => e?.note);
const font = needsFont ? await pdf.embedFont(StandardFonts.Helvetica) : null;
const bleed = sheet.bleedMm || 0;
const W = mmToPt(sheet.wMm), H = mmToPt(sheet.hMm);
pages.forEach((pg, i) => {
const page = pdf.addPage([W, H]);
for (const p of pg.placements) {
const im = embedded[p.specId];
if (!im) continue;
// Bild inkl. Beschnittzugabe: ragt je Seite um `bleed` über die Trimmbox.
const x = mmToPt(p.x - bleed);
const yTop = p.y - bleed; // von oben gezählt
const w = mmToPt(p.w + 2 * bleed);
const h = mmToPt(p.h + 2 * bleed);
page.drawImage(im, { x, y: H - mmToPt(yTop) - h, width: w, height: h });
}
for (const l of marksPerPage[i] || []) {
page.drawLine({
start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) },
end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) },
thickness: input.markWidthPt ?? 0.25,
color: rgb(0, 0, 0),
});
}
// Klebefalz beim Posterdruck: gestrichelt, damit man sie nicht für eine
// Schnittkante hält.
const extras = input.extrasPerPage?.[i];
for (const l of extras?.dashed || []) {
page.drawLine({
start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) },
end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) },
thickness: 0.4, color: rgb(0.55, 0.55, 0.52), dashArray: [4, 3],
});
}
if (font) {
const put = (text: string, pos: FooterPos, size: number) => {
const w = font.widthOfTextAtSize(text, size);
const top = pos.startsWith('oben');
const y = top ? H - mmToPt(3) - size : mmToPt(3);
const x = pos.endsWith('mitte') ? (W - w) / 2
: pos.endsWith('rechts') ? W - mmToPt(4) - w
: mmToPt(4);
page.drawText(text, { x, y, size, font, color: rgb(0.45, 0.45, 0.42), rotate: degrees(0) });
};
// Fußzeile nur, wenn an der gewählten Kante wirklich Platz ist —
// sonst stünde sie mitten im Motiv.
if (input.footer?.text) {
const pos = input.footer.position || 'unten-links';
const size = input.footer.sizePt ?? 6;
const top = pos.startsWith('oben');
const free = pg.placements.length
? (top ? Math.min(...pg.placements.map((p) => p.y - bleed))
: sheet.hMm - Math.max(...pg.placements.map((p) => p.y + p.h + bleed)))
: sheet.hMm;
if (free >= size * 0.353 + 4) put(input.footer.text, pos, size);
}
// Blattnummer beim Posterdruck steht immer — sie ist die Montageanleitung.
if (extras?.note) put(extras.note, 'oben-links', 8);
}
});
return pdf.save();
}
+46
View File
@@ -0,0 +1,46 @@
// Auflösen der Bildquellen für den Druckbogen: frisch hochgeladen oder aus der Bibliothek.
import { one } from './db';
import { getObject } from './storage';
export type PrintSource =
| { kind: 'upload'; path: string }
| { kind: 'item'; id: string };
export interface SessionUser { uid: string | null; role: string }
/** Lädt die Bilddaten und prüft dabei die Zugriffsrechte wie /api/items/:id/file. */
export async function loadSource(src: PrintSource, user: SessionUser): Promise<Buffer> {
if (!src || typeof src !== 'object') throw new Error('Quelle fehlt.');
if (src.kind === 'upload') {
// Nur der Upload-Bereich ist erreichbar — kein Weg zu results/ oder /etc.
const p = String(src.path || '');
// \n bewusst ausschließen: JS-„$" matcht auch vor einem abschließenden Zeilenumbruch.
if (/[\r\n]/.test(p) || !/^sources\/[0-9]{4}\/[A-Za-z0-9_-]+\.[A-Za-z0-9]{2,5}$/.test(p))
throw new Error('Ungültige Quelle.');
return getObject(p);
}
if (src.kind === 'item') {
const item = await one<any>(
`SELECT i.result_path, i.filename, j.created_by, j.private
FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1`, [src.id]);
if (!item?.result_path) throw new Error('Bild nicht gefunden.');
const isAdmin = user.role === 'admin';
const own = item.created_by === user.uid;
if (!isAdmin && !own) {
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
if (item.private || s?.library_visibility !== 'shared') throw new Error('Kein Zugriff auf dieses Bild.');
}
return getObject(item.result_path);
}
throw new Error('Unbekannte Quellenart.');
}
/** Sprechender Dateiname für den Download. */
export function sheetFilename(prefix: string, ext: string): string {
const date = new Date().toISOString().slice(0, 10);
const slug = (prefix || 'druckbogen').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
return `${date}_${slug || 'druckbogen'}.${ext}`;
}
+108 -25
View File
@@ -1,13 +1,15 @@
import { one, query } from './db'; import { one, query } from './db';
import { getObject, putObject, resultKey } from './storage'; import { getObject, putObject, deleteObject, resultKey, thumbKey } from './storage';
import { generateImage } from './openrouter'; import { generateImage } from './openrouter';
import { buildPrompt, type Task } from './prompts'; import { buildPrompt, buildComposePrompt, buildGeneratePrompt, type Task } from './prompts';
import { resolveDimensions, aspectRatio, type Orientation } from './format'; import { resolveDimensions, aspectRatio, type Orientation } from './format';
import { finalizeToFormat, stickerContour, buildResultFilename, formatToken, type CropMode } from './pipeline'; import { finalizeToFormat, stickerContour, buildResultFilename, formatToken, type CropMode } from './pipeline';
import sharp from 'sharp'; import sharp from 'sharp';
const DEFAULT_MODEL = 'google/gemini-3.1-flash-image'; const DEFAULT_MODEL = 'google/gemini-3.1-flash-image';
type Mode = 'each' | 'compose' | 'generate';
interface RecipeSnapshot { interface RecipeSnapshot {
tasks: Task[]; tasks: Task[];
output_format?: string; output_format?: string;
@@ -17,8 +19,10 @@ interface RecipeSnapshot {
contour_mm?: number | null; contour_mm?: number | null;
model_key?: string | null; model_key?: string | null;
custom_instruction?: string | null; custom_instruction?: string | null;
prompt_text?: string | null; // Beschreibung für compose/generate
delivery?: 'library' | 'picdrop' | 'both'; delivery?: 'library' | 'picdrop' | 'both';
picdrop_gallery?: string | null; picdrop_gallery?: string | null;
output_ext?: 'png' | 'jpg' | null; // Dateiformat-Override; sonst globale Einstellung
} }
/** Welches Modell-ID + Alpha-Fähigkeit? Aus models-Tabelle, sonst Default. */ /** Welches Modell-ID + Alpha-Fähigkeit? Aus models-Tabelle, sonst Default. */
@@ -42,47 +46,84 @@ async function toDataUrl(buf: Buffer): Promise<string> {
return `data:image/jpeg;base64,${small.toString('base64')}`; return `data:image/jpeg;base64,${small.toString('base64')}`;
} }
/** Kleines Vorschaubild (webp) für die Bibliothek — spart Bandbreite & Speicher. */
async function makeThumb(buf: Buffer): Promise<Buffer> {
return sharp(buf, { failOn: 'none' })
.resize(600, 600, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 72 })
.toBuffer();
}
export interface ProcessResult { ok: boolean; cost: number; error?: string; } export interface ProcessResult { ok: boolean; cost: number; error?: string; }
/** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */ /** Verarbeitet eine Position vollständig und aktualisiert ihren Datensatz. */
export async function processItem(itemId: string): Promise<ProcessResult> { export async function processItem(itemId: string): Promise<ProcessResult> {
const item = await one<{ id: string; job_id: string; source_path: string; filename: string | null }>( const item = await one<{ id: string; job_id: string; source_path: string | null; source_paths: string[] | null; filename: string | null }>(
'SELECT id, job_id, source_path, filename FROM items WHERE id=$1', [itemId]); 'SELECT id, job_id, source_path, source_paths, filename FROM items WHERE id=$1', [itemId]);
if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' }; if (!item) return { ok: false, cost: 0, error: 'Position nicht gefunden' };
const job = await one<{ recipe_snapshot: RecipeSnapshot }>( const job = await one<{ recipe_snapshot: RecipeSnapshot; mode: Mode; private: boolean }>(
'SELECT recipe_snapshot FROM jobs WHERE id=$1', [item.job_id]); 'SELECT recipe_snapshot, mode, private FROM jobs WHERE id=$1', [item.job_id]);
const r = (job?.recipe_snapshot || {}) as RecipeSnapshot; const r = (job?.recipe_snapshot || {}) as RecipeSnapshot;
const mode: Mode = job?.mode || 'each';
const isPrivate = !!job?.private;
const tasks = r.tasks || []; const tasks = r.tasks || [];
const dpi = r.dpi ?? 300; const dpi = r.dpi ?? 300;
const cropMode: CropMode = r.crop_mode === 'extend' ? 'extend' : 'crop'; const cropMode: CropMode = r.crop_mode === 'extend' ? 'extend' : 'crop';
const wantCutout = tasks.includes('cutout'); const wantCutout = tasks.includes('cutout');
const wantContour = tasks.includes('contour'); const wantContour = tasks.includes('contour');
const description = (r.prompt_text || '').trim();
await query(`UPDATE items SET status='running', attempts=attempts+1 WHERE id=$1`, [itemId]); await query(`UPDATE items SET status='running', attempts=attempts+1 WHERE id=$1`, [itemId]);
try { try {
const source = await getObject(item.source_path); // Quellen je Modus einsammeln
const target = r.tasks.includes('format') && r.output_format const sourceKeys: string[] =
mode === 'compose' ? (item.source_paths || []).filter(Boolean)
: mode === 'generate' ? []
: (item.source_path ? [item.source_path] : []);
// Braucht dieser Modus eine Quelle, ist aber keine (mehr) da? Klare Meldung statt roher fs-Fehler.
if (mode !== 'generate' && sourceKeys.length === 0) {
const e: any = new Error('Quelle nicht mehr vorhanden — bitte das Bild erneut hochladen.');
e.friendly = 'Quelle nicht mehr vorhanden — bitte das Bild erneut hochladen.';
throw e;
}
const sources = await Promise.all(sourceKeys.map(async (k) => {
try { return await getObject(k); }
catch {
const e: any = new Error('Quelldatei nicht mehr vorhanden — bitte das Bild erneut hochladen.');
e.friendly = 'Quelldatei nicht mehr vorhanden — bitte das Bild erneut hochladen.';
throw e;
}
}));
const target = tasks.includes('format') && r.output_format
? resolveDimensions({ format: r.output_format, orientation: r.orientation, dpi }) ? resolveDimensions({ format: r.output_format, orientation: r.orientation, dpi })
: null; : null;
const model = await pickModel(r.model_key); const model = await pickModel(r.model_key);
const prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction });
// Nur Modell aufrufen, wenn eine generative Aufgabe dabei ist. // Prompt + ob das Modell überhaupt gebraucht wird, je Modus.
const needsModel = tasks.includes('clean') || wantCutout || let prompt: string;
(tasks.includes('format') && cropMode === 'extend') || !!r.custom_instruction; let needsModel: boolean;
if (mode === 'compose') { prompt = buildComposePrompt(description, cropMode === 'extend', sourceKeys.length); needsModel = true; }
else if (mode === 'generate') { prompt = buildGeneratePrompt(description); needsModel = true; }
else {
prompt = buildPrompt({ tasks, cropMode, customInstruction: r.custom_instruction });
needsModel = tasks.includes('clean') || wantCutout ||
(tasks.includes('format') && cropMode === 'extend') || !!r.custom_instruction;
}
let working = source; let working = sources[0] || Buffer.alloc(0);
let modelUsed: string | null = null; let modelUsed: string | null = null;
let cost = 0; let cost = 0;
if (needsModel) { if (needsModel) {
const inputUrls = sources.length ? await Promise.all(sources.map(toDataUrl)) : undefined;
const gen = await generateImage({ const gen = await generateImage({
model: model.id, model: model.id,
prompt, prompt,
inputUrl: await toDataUrl(source), inputUrls,
aspectRatio: target ? aspectRatio(target.w, target.h) : undefined, aspectRatio: target ? aspectRatio(target.w, target.h) : undefined,
background: wantCutout ? 'transparent' : undefined, background: wantCutout ? 'transparent' : undefined,
outputFormat: 'png', outputFormat: 'png',
@@ -91,6 +132,7 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
modelUsed = gen.model; modelUsed = gen.model;
cost = gen.cost; cost = gen.cost;
} }
if (!working.length) throw new Error('Keine Bilddaten erzeugt');
// Lokale Nachbearbeitung: exakt aufs Zielmaß + dpi // Lokale Nachbearbeitung: exakt aufs Zielmaß + dpi
const fin = await finalizeToFormat(working, target, cropMode, dpi); const fin = await finalizeToFormat(working, target, cropMode, dpi);
@@ -102,30 +144,71 @@ export async function processItem(itemId: string): Promise<ProcessResult> {
hasAlpha = true; hasAlpha = true;
} }
const key = resultKey(item.id); // Ausgabeformat: Rezept-Override, sonst globale Einstellung. JPG nur ohne Transparenz.
await putObject(key, outBuf, 'image/png'); const gset = await one<{ output_ext: string | null }>('SELECT output_ext FROM settings WHERE id=1');
const wantExt = (r.output_ext || gset?.output_ext || 'png').toLowerCase();
let ext = 'png';
let contentType = 'image/png';
if (wantExt === 'jpg' && !hasAlpha) {
outBuf = await sharp(outBuf, { failOn: 'none' })
.flatten({ background: '#ffffff' })
.withMetadata({ density: dpi })
.jpeg({ quality: 92, mozjpeg: true, chromaSubsampling: '4:4:4' })
.toBuffer();
ext = 'jpg';
contentType = 'image/jpeg';
}
const filename = buildResultFilename(item.filename, formatToken(r.output_format), 'png'); const key = resultKey(item.id);
await putObject(key, outBuf, contentType);
// Vorschaubild (optional, laut Einstellung)
const cfg = await one<{ make_thumbnails: boolean; keep_sources: boolean; nas_enabled: boolean }>(
'SELECT make_thumbnails, keep_sources, nas_enabled FROM settings WHERE id=1');
let thumbPath: string | null = null;
if (cfg?.make_thumbnails !== false) {
try {
const tk = thumbKey(item.id);
await putObject(tk, await makeThumb(outBuf), 'image/webp');
thumbPath = tk;
} catch (e) { console.error('[process] Thumbnail fehlgeschlagen', e); }
}
const filename = buildResultFilename(item.filename, formatToken(r.output_format), ext);
const outputPx = `${fin.width}x${fin.height}`; const outputPx = `${fin.width}x${fin.height}`;
const deliveryStatus = (r.delivery === 'picdrop' || r.delivery === 'both') ? 'pending' : 'none'; // Private Aufträge: kein Delivery, kein Backup, kein gespeicherter Prompt.
const deliveryStatus = (!isPrivate && (r.delivery === 'picdrop' || r.delivery === 'both')) ? 'pending' : 'none';
const nasStatus = (!isPrivate && cfg?.nas_enabled) ? 'pending' : 'none';
await query( await query(
`UPDATE items SET status='done', result_path=$2, filename=$3, output_px=$4, dpi=$5, `UPDATE items SET status='done', result_path=$2, thumb_path=$3, filename=$4, output_px=$5, dpi=$6,
has_alpha=$6, model_used=$7, prompt_used=$8, cost=$9, error_message=NULL, has_alpha=$7, model_used=$8, prompt_used=$9, cost=$10, error_message=NULL,
delivery_status=$10 WHERE id=$1`, delivery_status=$11, nas_status=$12 WHERE id=$1`,
[itemId, key, filename, outputPx, dpi, hasAlpha, modelUsed, prompt.slice(0, 1000), [itemId, key, thumbPath, filename, outputPx, dpi, hasAlpha, modelUsed,
cost, deliveryStatus]); isPrivate ? null : prompt.slice(0, 1000), cost, deliveryStatus, nasStatus]);
// Quellbilder löschen, wenn nicht behalten — bei privaten Aufträgen immer.
if (isPrivate || cfg?.keep_sources === false) {
for (const k of sourceKeys) await deleteObject(k).catch(() => {});
if (isPrivate) await query(`UPDATE items SET source_path=NULL, source_paths=NULL WHERE id=$1`, [itemId]);
}
// Zweite Sicherung auf alle Backup-Ziele — nicht bei privaten Aufträgen.
if (!isPrivate) {
try {
const { backupItem } = await import('./backup');
await backupItem(itemId);
} catch (e) { console.error('[process] Backup fehlgeschlagen', e); }
}
return { ok: true, cost }; return { ok: true, cost };
} catch (e: any) { } catch (e: any) {
const real = e?.message || String(e); const real = e?.message || String(e);
const status = e?.status ? ` [status ${e.status}]` : ''; const status = e?.status ? ` [status ${e.status}]` : '';
console.error(`[process] Item ${itemId} fehlgeschlagen${status}: ${real}`, e?.stack || ''); console.error(`[process] Item ${itemId} fehlgeschlagen${status}: ${real}`, e?.stack || '');
// Nutzeranzeige: freundlich, aber mit kompaktem Grund fürs Debugging in dieser Phase
const msg = (e?.friendly ? `${e.friendly}` : real) + (e?.status ? ` (${e.status})` : ''); const msg = (e?.friendly ? `${e.friendly}` : real) + (e?.status ? ` (${e.status})` : '');
await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`, await query(`UPDATE items SET status='failed', error_message=$2 WHERE id=$1`,
[itemId, String(msg).slice(0, 500)]); [itemId, String(msg).slice(0, 500)]);
// 402/Guthaben: nach oben durchreichen, damit der Worker den Auftrag anhält
if (e?.status === 402) throw e; if (e?.status === 402) throw e;
return { ok: false, cost: 0, error: msg }; return { ok: false, cost: 0, error: msg };
} }
+30
View File
@@ -43,3 +43,33 @@ export function buildPrompt({ tasks, cropMode, customInstruction }: PromptOpts):
parts.push('Output only the resulting image.'); parts.push('Output only the resulting image.');
return parts.join(' '); return parts.join(' ');
} }
/** Kombinieren/Umwandeln: eine ODER mehrere Vorlagen + Beschreibung → EIN neues Bild.
* Bei einer Vorlage = Bild gemäß Anweisung umwandeln (z. B. Foto → Ölgemälde).
* Bei mehreren = erste ist Leitbild, weitere liefern Elemente/Personen/Motive. */
export function buildComposePrompt(description: string, extend = false, count = 2): string {
const parts: string[] = [];
if (count <= 1) {
parts.push(
'Transform the given image according to the instruction below into ONE new, ' +
'high-resolution image. Keep the subject, composition and important details recognizable ' +
'unless the instruction explicitly asks to change them.');
} else {
parts.push(
'You are given several reference images. Combine them into ONE new, coherent, ' +
'high-resolution image that follows the instruction below. Treat the first image as ' +
'the main scene/style reference and use the other images as elements to integrate ' +
'(people, pets, objects) — match their identity, colors and lighting faithfully so ' +
'they look naturally part of the same photo.');
}
if (description.trim()) parts.push(`Instruction: ${description.trim()}`);
if (extend) parts.push('Extend the scene naturally to fill the requested aspect ratio (outpainting).');
parts.push('Output only the resulting image.');
return parts.join(' ');
}
/** Freitext: gar keine Vorlage → ein komplett neues Bild aus der Beschreibung. */
export function buildGeneratePrompt(description: string): string {
const d = description.trim() || 'A clean, high-quality image.';
return `Create a new, high-resolution image. ${d} Output only the resulting image.`;
}
+42 -1
View File
@@ -15,6 +15,10 @@ export async function seed(): Promise<void> {
('google/gemini-3.1-flash-image','Schnell','In wenigen Sekunden fertig. Für den Alltag.',true,true,true,0), ('google/gemini-3.1-flash-image','Schnell','In wenigen Sekunden fertig. Für den Alltag.',true,true,true,0),
('google/gemini-3-pro-image','Beste Qualität','Dauert länger, trifft Schrift und Details genauer.',true,false,true,1)`); ('google/gemini-3-pro-image','Beste Qualität','Dauert länger, trifft Schrift und Details genauer.',true,false,true,1)`);
} }
// Open-Source-Option (FLUX.2, offene Gewichte) nachrüsten — auch für bestehende Installationen.
await query(`INSERT INTO models (model_id, label, description, active, is_default, supports_alpha, sort)
SELECT 'black-forest-labs/flux.2-flex','Open Source (FLUX.2)','Offene Gewichte, gute Qualität. Kein transparentes Freistellen.',true,false,false,2
WHERE NOT EXISTS (SELECT 1 FROM models WHERE model_id='black-forest-labs/flux.2-flex')`);
// Nutzer // Nutzer
const till = await one(`SELECT id FROM users WHERE username='till'`); const till = await one(`SELECT id FROM users WHERE username='till'`);
@@ -44,8 +48,45 @@ export async function seed(): Promise<void> {
await R('Nur bereinigen', ['clean'], 'keep', 'landscape', { is_default: true }); await R('Nur bereinigen', ['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('The Frame', ['clean', 'format'], 'theframe', 'landscape'); await R('The Frame', ['clean', 'format'], 'theframe', 'landscape',
{ delivery: 'both', picdrop_gallery: 'TheFrame-Backgrounds' });
await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape', await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape',
{ contour_mm: 3 }); { contour_mm: 3 });
} }
// Druck-Vorlagen (Bogen ohne KI) — idempotent über den Namen, auch für bestehende Installationen.
{
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE username='till'`);
const by = admin?.id ?? null;
const P = async (name: string, config: Record<string, unknown>) => {
const exists = await one(`SELECT id FROM print_presets WHERE name=$1`, [name]);
if (exists) return;
await query(`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3)`,
[name, by, JSON.stringify(config)]);
};
const sheet = {
paperId: 'A4', paperCustom: '', paperLandscape: false, marginMm: 5,
autoGap: true, gapMm: 14, bleedMm: 0,
marks: 'corner', markLen: 4, markOff: 3, dpi: 300, ext: 'jpg', center: true, footer: true,
};
const F = (sizeId: string, count: number, landscape = false) =>
({ sizeId, customSize: '', landscape, count, allowRotate: true });
// Der klassische Kita-/Schulfoto-Satz aus einem Bild.
await P('Kita-Satz (A4)', { ...sheet, formats: [F('S13x18', 1), F('S9x13', 2), F('P35x45', 8)] });
// Nur die kleinen Abzüge, wenn der große schon gedruckt ist.
await P('Schulsatz klein (A4)', { ...sheet, formats: [F('S9x13', 4), F('K30x40', 8)] });
// Voller Passbildbogen — durchgehende Linien, weil alles gleich groß ist.
await P('Passbildbogen 35×45 (A4)', { ...sheet, marks: 'grid', autoGap: false, gapMm: 4, formats: [F('P35x45', 20)] });
// Zwei Bilder in Originalgröße auf ein Blatt 10×15-Fotopapier.
await P('2 × 10×7,5 auf Fotopapier 10×15', {
...sheet, paperId: 'F10x15', marginMm: 0, marks: 'grid', autoGap: false, gapMm: 0,
formats: [{ sizeId: 'custom', customSize: '10x7,5', landscape: false, count: 2, allowRotate: true }],
});
}
// The-Frame-Rezept auf die eigene Galerie verdrahten (auch für bestehende Installationen).
await query(
`UPDATE recipes SET picdrop_gallery='TheFrame-Backgrounds', delivery='both'
WHERE output_format='theframe' AND (picdrop_gallery IS NULL OR picdrop_gallery='')`);
} }
+3
View File
@@ -90,3 +90,6 @@ export function sourceKey(uuid: string, ext: string): string {
export function resultKey(uuid: string): string { export function resultKey(uuid: string): string {
return `results/${new Date().getFullYear()}/${uuid}.png`; return `results/${new Date().getFullYear()}/${uuid}.png`;
} }
export function thumbKey(uuid: string): string {
return `thumbs/${new Date().getFullYear()}/${uuid}.webp`;
}
+303 -43
View File
@@ -1,17 +1,43 @@
// Telegram-Bot (grammY, Webhook) — vollwertiger Eingangs- und Bedien-Kanal. // Telegram-Bot (grammY, Webhook) — vollwertiger Eingangs- und Bedien-Kanal.
// Kopplung, Bündelung weitergeleiteter Bilder, Rezeptwahl, Verarbeitung, Rückmeldung. // Kopplung, Bündelung weitergeleiteter Bilder, Rezeptwahl, Verarbeitung, Rückmeldung.
import { Bot, InputFile, InlineKeyboard } from 'grammy'; import { Bot, InputFile, InlineKeyboard, Keyboard } from 'grammy';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { one, query } from './db'; import { one, query } from './db';
import { decrypt } from './crypto'; import { decrypt } from './crypto';
import { getObject, putObject, sourceKey } from './storage'; import { getObject, putObject, sourceKey } from './storage';
import { enqueue } from './queue'; import { enqueue } from './queue';
import { layout, cutMarks, recommendedGap, type SheetSpec } from './printlayout';
import { renderCell, buildSheetPdf } from './printrender';
import { photoById, labelMm, paperById } from './paper';
const QUIET_MS = 4000; // Ruhefenster für die Bündelung const QUIET_MS = 4000; // Ruhefenster für die Bündelung
const BASE = process.env.PUBLIC_BASE_URL || ''; const BASE = process.env.PUBLIC_BASE_URL || '';
let bot: Bot | null = null; let bot: Bot | null = null;
let webhookSecret = ''; let webhookSecret = '';
// Zwischenspeicher: Freitext, aus dem der Nutzer per Knopf ein Bild erzeugen kann.
const pendingGenerate = new Map<number, string>();
// Chats, die gerade eine Bildbeschreibung eintippen (nach Tippen auf „Neues Bild").
const awaitingGenerate = new Set<number>();
// Menü-Beschriftungen (Dauertastatur) — alles per Fingertipp, ohne Befehle.
const BTN_NEW = '✨ Neues Bild', BTN_RECIPES = '📁 Rezept wählen', BTN_HELP = '❓ Hilfe';
function mainKeyboard() {
return new Keyboard().text(BTN_NEW).row().text(BTN_RECIPES).text(BTN_HELP).resized().persistent();
}
// Druckbogen per Telegram: feste, knopfbare Auswahl — kein Freitext nötig.
const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S13x18', 'R30x40'];
const PRINT_COUNTS = [1, 2, 4, 8, 0]; // 0 = „Bogen füllen"
const HELP_TEXT =
'So funktionierts — alles per Knopf, ohne Befehle:\n\n' +
'🖼 *Bearbeiten:* Bild(er) hierher schicken (am besten als *Datei*), dann unten das Rezept antippen.\n' +
'🔀 *Kombinieren:* 2+ Bilder schicken, ins Bild-Textfeld eine Beschreibung wie „mit unserer Hündin Frieda" — danach „🔀 Kombinieren" antippen.\n' +
'✨ *Neues Bild:* unten „✨ Neues Bild" tippen und beschreiben, was entstehen soll.\n' +
'♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n' +
'🖨 *Drucken (ohne KI):* Bild schicken → „🖨 Drucken" → Maß und Anzahl antippen. ' +
'Du bekommst ein PDF in 100 %-Größe mit Schnittmarken.\n\n' +
'Ich melde mich einmal, wenn alles fertig ist — mit Ergebnis und Link.';
export async function getToken(): Promise<string> { export async function getToken(): Promise<string> {
const s = await one<{ telegram_bot_token_enc: string | null }>('SELECT telegram_bot_token_enc FROM settings WHERE id=1'); const s = await one<{ telegram_bot_token_enc: string | null }>('SELECT telegram_bot_token_enc FROM settings WHERE id=1');
@@ -46,6 +72,16 @@ export async function setupTelegram(): Promise<void> {
console.log('[telegram] Webhook gesetzt:', `${BASE}/api/telegram/webhook`); console.log('[telegram] Webhook gesetzt:', `${BASE}/api/telegram/webhook`);
} catch (e) { console.error('[telegram] setWebhook fehlgeschlagen:', e); } } catch (e) { console.error('[telegram] setWebhook fehlgeschlagen:', e); }
} }
// Befehlsmenü (das „/"-Menü in Telegram) — als zusätzliche Abkürzung.
try {
await b.api.setMyCommands([
{ command: 'neu', description: '✨ Neues Bild aus Text erzeugen' },
{ command: 'rezepte', description: '📁 Standard-Rezept wählen' },
{ command: 'status', description: '⏳ Letzten Auftrag anzeigen' },
{ command: 'hilfe', description: '❓ Hilfe & Anleitung' },
{ command: 'start', description: '🏠 Menü zeigen' },
]);
} catch (e) { console.error('[telegram] setMyCommands:', e); }
setInterval(() => { sweepDrafts().catch((e) => console.error('[telegram] sweep', e)); }, 3000); setInterval(() => { sweepDrafts().catch((e) => console.error('[telegram] sweep', e)); }, 3000);
} }
@@ -66,20 +102,22 @@ async function tryPair(chatId: number, code: string): Promise<boolean> {
} }
// --- Bündelung (Drafts) ----------------------------------------------------- // --- Bündelung (Drafts) -----------------------------------------------------
async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: any, noticeMsgId?: number) { async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: any, caption?: string | null) {
const existing = await one<{ id: string; file_refs: any[] }>( const existing = await one<{ id: string; file_refs: any[]; caption: string | null }>(
`SELECT id, file_refs FROM telegram_drafts WHERE chat_id=$1 AND status='collecting' `SELECT id, file_refs, caption FROM telegram_drafts WHERE chat_id=$1 AND status='collecting'
ORDER BY last_received_at DESC LIMIT 1`, [chatId]); ORDER BY last_received_at DESC LIMIT 1`, [chatId]);
if (existing) { if (existing) {
const refs = [...(existing.file_refs || []), fileRef]; const refs = [...(existing.file_refs || []), fileRef];
await query(`UPDATE telegram_drafts SET file_refs=$2, last_received_at=now() WHERE id=$1`, // Erste vorhandene Bildunterschrift als Beschreibung merken.
[existing.id, JSON.stringify(refs)]); const cap = existing.caption || (caption?.trim() || null);
await query(`UPDATE telegram_drafts SET file_refs=$2, caption=$3, last_received_at=now() WHERE id=$1`,
[existing.id, JSON.stringify(refs), cap]);
return existing.id; return existing.id;
} }
const row = await one<{ id: string }>( const row = await one<{ id: string }>(
`INSERT INTO telegram_drafts (chat_id, media_group_id, file_refs, status, notice_message_id) `INSERT INTO telegram_drafts (chat_id, media_group_id, file_refs, caption, status)
VALUES ($1,$2,$3,'collecting',$4) RETURNING id`, VALUES ($1,$2,$3,$4,'collecting') RETURNING id`,
[chatId, mediaGroup, JSON.stringify([fileRef]), noticeMsgId ?? null]); [chatId, mediaGroup, JSON.stringify([fileRef]), caption?.trim() || null]);
return row!.id; return row!.id;
} }
@@ -87,18 +125,22 @@ async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: an
export async function sweepDrafts(): Promise<void> { export async function sweepDrafts(): Promise<void> {
const b = await getBot(); if (!b) return; const b = await getBot(); if (!b) return;
const drafts = await query<any>( const drafts = await query<any>(
`SELECT id, chat_id, file_refs FROM telegram_drafts `SELECT id, chat_id, file_refs, caption FROM telegram_drafts
WHERE status='collecting' AND last_received_at < now() - interval '${Math.round(QUIET_MS / 1000)} seconds'`); WHERE status='collecting' AND last_received_at < now() - interval '${Math.round(QUIET_MS / 1000)} seconds'`);
for (const d of drafts) { for (const d of drafts) {
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 8'); const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 6');
const kb = new InlineKeyboard(); const kb = new InlineKeyboard();
// Callback-Daten <64 Bytes: nur Rezept-UUID; der Draft wird beim Klick über den Chat gefunden. // Callback-Daten <64 Bytes: nur Rezept-UUID; der Draft wird beim Klick über den Chat gefunden.
recipes.forEach((r, i) => { kb.text(r.name, `r:${r.id}`); if (i % 2 === 1) kb.row(); }); recipes.forEach((r, i) => { kb.text(r.name, `r:${r.id}`); if (i % 2 === 1) kb.row(); });
const n = (d.file_refs || []).length; const n = (d.file_refs || []).length;
// Ab 2 Bildern zusätzlich „Kombinieren" anbieten.
if (n >= 2) { kb.row(); kb.text('🔀 Zu einem Bild kombinieren', 'c:x'); }
kb.row(); kb.text('🖨 Drucken (ohne KI)', 'p:menu');
const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed'); const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : '';
try { try {
await b.api.sendMessage(d.chat_id, await b.api.sendMessage(d.chat_id,
`${n} Bild${n > 1 ? 'er' : ''} empfangen. Welches Rezept?` + `${n} Bild${n > 1 ? 'er' : ''} empfangen. Was möchtest du tun?` + capNote +
(compressed ? '\n⚠️ Als Foto gesendet (komprimiert). Für volle Qualität als *Datei* senden.' : ''), (compressed ? '\n⚠️ Als Foto gesendet (komprimiert). Für volle Qualität als *Datei* senden.' : ''),
{ reply_markup: kb, parse_mode: 'Markdown' }); { reply_markup: kb, parse_mode: 'Markdown' });
await query(`UPDATE telegram_drafts SET status='awaiting_recipe' WHERE id=$1`, [d.id]); await query(`UPDATE telegram_drafts SET status='awaiting_recipe' WHERE id=$1`, [d.id]);
@@ -107,19 +149,12 @@ export async function sweepDrafts(): Promise<void> {
} }
// --- Draft -> Auftrag ------------------------------------------------------- // --- Draft -> Auftrag -------------------------------------------------------
async function dispatchDraft(chatId: number, draftId: string, recipeId: string, b: Bot) {
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
if (!draft || draft.status === 'dispatched') return;
const recipe = await one<any>('SELECT * FROM recipes WHERE id=$1', [recipeId]);
if (!recipe) { await b.api.sendMessage(chatId, 'Rezept nicht gefunden.'); return; }
const link = await linkedUser(chatId);
await b.api.sendMessage(chatId, `⏳ Verarbeite ${(draft.file_refs || []).length} Bild(er) …`); /** Lädt alle Bilder eines Drafts von Telegram und legt sie in den Objektspeicher. */
async function refsToSources(refs: any[], b: Bot): Promise<any[]> {
// Bilder von Telegram laden und in den Objektspeicher legen
const sources: any[] = []; const sources: any[] = [];
const token = await getToken(); const token = await getToken();
for (const ref of draft.file_refs || []) { for (const ref of refs || []) {
try { try {
const file = await b.api.getFile(ref.file_id); const file = await b.api.getFile(ref.file_id);
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`; const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
@@ -129,6 +164,32 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
sources.push({ source_path: key, filename: ref.name || 'telegram.jpg', quality: ref.quality || 'original' }); sources.push({ source_path: key, filename: ref.name || 'telegram.jpg', quality: ref.quality || 'original' });
} catch (e) { console.error('[telegram] Datei laden fehlgeschlagen', e); } } catch (e) { console.error('[telegram] Datei laden fehlgeschlagen', e); }
} }
return sources;
}
/** Basis-Snapshot aus dem Standard-Rezept des Chats (Format/Modell/Auslieferung). */
async function chatRecipeDefaults(chatId: number): Promise<any> {
const link = await linkedUser(chatId);
if (link?.default_recipe_id) {
const r = await one<any>('SELECT * FROM recipes WHERE id=$1', [link.default_recipe_id]);
if (r) return {
output_format: r.output_format, orientation: r.orientation, crop_mode: r.crop_mode || 'crop',
dpi: r.dpi || 300, model_key: r.model_key, delivery: r.delivery || 'library',
picdrop_gallery: r.picdrop_gallery, output_ext: r.output_ext, delivery_target_id: r.delivery_target_id,
};
}
return { output_format: 'keep', crop_mode: 'crop', dpi: 300, delivery: 'library', output_ext: null };
}
async function dispatchDraft(chatId: number, draftId: string, recipeId: string, b: Bot) {
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
if (!draft || draft.status === 'dispatched') return;
const recipe = await one<any>('SELECT * FROM recipes WHERE id=$1', [recipeId]);
if (!recipe) { await b.api.sendMessage(chatId, 'Rezept nicht gefunden.'); return; }
const link = await linkedUser(chatId);
await b.api.sendMessage(chatId, `⏳ Verarbeite ${(draft.file_refs || []).length} Bild(er) …`);
const sources = await refsToSources(draft.file_refs || [], b);
if (!sources.length) { await b.api.sendMessage(chatId, '❌ Keine Bilder ladbar.'); return; } if (!sources.length) { await b.api.sendMessage(chatId, '❌ Keine Bilder ladbar.'); return; }
const snap = { const snap = {
@@ -136,10 +197,11 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
crop_mode: recipe.crop_mode || 'crop', dpi: recipe.dpi || 300, contour_mm: recipe.contour_mm, crop_mode: recipe.crop_mode || 'crop', dpi: recipe.dpi || 300, contour_mm: recipe.contour_mm,
model_key: recipe.model_key, custom_instruction: recipe.custom_instruction, model_key: recipe.model_key, custom_instruction: recipe.custom_instruction,
delivery: recipe.delivery || 'library', picdrop_gallery: recipe.picdrop_gallery, delivery: recipe.delivery || 'library', picdrop_gallery: recipe.picdrop_gallery,
output_ext: recipe.output_ext, delivery_target_id: recipe.delivery_target_id,
}; };
const job = await one<{ id: string }>( const job = await one<{ id: string }>(
`INSERT INTO jobs (created_by, origin, recipe_snapshot, status, total, telegram_chat_id) `INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
VALUES ($1,'telegram',$2,'queued',$3,$4) RETURNING id`, VALUES ($1,'telegram','each',$2,'queued',$3,$4) RETURNING id`,
[link?.user_id || null, JSON.stringify(snap), sources.length, chatId]); [link?.user_id || null, JSON.stringify(snap), sources.length, chatId]);
for (let i = 0; i < sources.length; i++) { for (let i = 0; i < sources.length; i++) {
const it = await one<{ id: string }>( const it = await one<{ id: string }>(
@@ -151,6 +213,114 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]); await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
} }
/** Kombinieren: alle Bilder des Drafts + Beschreibung → ein neues Bild. */
async function dispatchCompose(chatId: number, draftId: string, description: string, b: Bot) {
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
if (!draft || draft.status === 'dispatched') return;
const link = await linkedUser(chatId);
await b.api.sendMessage(chatId, '⏳ Kombiniere die Bilder …');
const sources = await refsToSources(draft.file_refs || [], b);
if (sources.length < 2) { await b.api.sendMessage(chatId, '❌ Zum Kombinieren brauche ich mindestens 2 Bilder.'); return; }
const d = await chatRecipeDefaults(chatId);
const wantsFormat = d.output_format && d.output_format !== 'keep';
const snap = {
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
prompt_text: description, delivery: d.delivery || 'library', picdrop_gallery: d.picdrop_gallery,
output_ext: d.output_ext, delivery_target_id: d.delivery_target_id,
};
const job = await one<{ id: string }>(
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
VALUES ($1,'telegram','compose',$2,'queued',1,$3) RETURNING id`,
[link?.user_id || null, JSON.stringify(snap), chatId]);
const it = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, source_paths, filename, source_quality)
VALUES ($1,0,'queued',$2,'kombiniert','original') RETURNING id`,
[job!.id, JSON.stringify(sources.map((s) => s.source_path))]);
await enqueue({ itemId: it!.id, jobId: job!.id });
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
}
/** Freitext: ein komplett neues Bild allein aus der Beschreibung. */
async function dispatchGenerate(chatId: number, description: string, b: Bot) {
const link = await linkedUser(chatId);
await b.api.sendMessage(chatId, '⏳ Erzeuge ein neues Bild …');
const d = await chatRecipeDefaults(chatId);
const wantsFormat = d.output_format && d.output_format !== 'keep';
const snap = {
tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation,
crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key,
prompt_text: description, delivery: d.delivery || 'library', picdrop_gallery: d.picdrop_gallery,
output_ext: d.output_ext, delivery_target_id: d.delivery_target_id,
};
const job = await one<{ id: string }>(
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id)
VALUES ($1,'telegram','generate',$2,'queued',1,$3) RETURNING id`,
[link?.user_id || null, JSON.stringify(snap), chatId]);
const it = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, filename, source_quality)
VALUES ($1,0,'queued','neu','original') RETURNING id`, [job!.id]);
await enqueue({ itemId: it!.id, jobId: job!.id });
}
/**
* Druckbogen ohne KI: Bilder des Drafts auf ein Maß bringen, auf A4 setzen,
* Schnittmarken dazu, als PDF zurückschicken. Läuft direkt (keine Warteschlange,
* kein Modell, keine Kosten).
*/
async function dispatchPrint(chatId: number, draftId: string, sizeId: string, count: number, b: Bot) {
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
if (!draft) { await b.api.sendMessage(chatId, 'Kein offener Bild-Stapel gefunden.'); return; }
const size = photoById(sizeId);
if (!size) { await b.api.sendMessage(chatId, 'Unbekanntes Maß.'); return; }
await b.api.sendMessage(chatId, '📐 Baue den Druckbogen …');
const sources = await refsToSources(draft.file_refs || [], b);
if (!sources.length) { await b.api.sendMessage(chatId, '❌ Keine Bilder ladbar.'); return; }
const a4 = paperById('A4')!;
const sheet: SheetSpec = {
wMm: a4.w, hMm: a4.h, marginMm: 5,
gapMm: recommendedGap('corner', 0, 4, 3), bleedMm: 0, center: true,
};
// „Bogen füllen": so oft wie möglich, gleichmäßig auf die Bilder verteilt.
let per = count;
if (!per) {
const probe = layout([{ id: 'x', wMm: size.w, hMm: size.h, count: 200, allowRotate: true }], sheet);
per = Math.max(1, Math.floor((probe.pages[0]?.placements.length || 1) / sources.length));
}
const specs = sources.map((s, i) => ({ id: `s${i}`, wMm: size.w, hMm: size.h, count: per, allowRotate: true }));
const plan = layout(specs, sheet);
if (!plan.pages.length) { await b.api.sendMessage(chatId, '❌ Das Maß passt nicht auf A4.'); return; }
const images: Record<string, { bytes: Buffer; ext: 'jpg' | 'png' }> = {};
for (const [i, src] of sources.entries()) {
const buf = await getObject(src.source_path);
const rots = new Set(plan.pages.flatMap((pg) => pg.placements.filter((p) => p.specId === `s${i}`).map((p) => p.rotated)));
for (const rot of rots) {
const out = await renderCell(buf, null, size.w, size.h, 300, { ext: 'jpg', rotate: rot });
images[rot ? `s${i}::rot` : `s${i}`] = { bytes: out.buffer, ext: out.ext };
}
}
const pages = plan.pages.map((pg) => ({
placements: pg.placements.map((p) => ({ ...p, specId: p.rotated ? `${p.specId}::rot` : p.specId })),
}));
const marks = plan.pages.map((pg) => cutMarks(pg, sheet, { mode: 'corner', lengthMm: 4, offsetMm: 3 }));
const label = labelMm(size.w, size.h);
const bytes = await buildSheetPdf({
sheet, pages, marksPerPage: marks, images, title: `Klarbild Druckbogen ${label}`,
footer: `Klarbild · DIN A4 · ${label} · 300 dpi · Druck bei 100 % (nicht „an Seite anpassen")`,
});
const file = new InputFile(Buffer.from(bytes), `klarbild-druckbogen-${label.replace(/[^0-9]+/g, 'x')}.pdf`);
await b.api.sendDocument(chatId, file, {
caption: `📐 ${plan.pages.length} Bogen · ${plan.pages[0].placements.length} Bild(er) auf Bogen 1 · ${label}\n` +
'Beim Drucken „Tatsächliche Größe / 100 %" wählen — sonst stimmt das Maß nicht.',
});
await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]);
}
/** Vom Worker aufgerufen: eine Rückmeldung an den Chat, wenn der Auftrag fertig ist. */ /** Vom Worker aufgerufen: eine Rückmeldung an den Chat, wenn der Auftrag fertig ist. */
export async function notifyJobDone(chatId: number, jobId: string): Promise<void> { export async function notifyJobDone(chatId: number, jobId: string): Promise<void> {
const b = await getBot(); if (!b) return; const b = await getBot(); if (!b) return;
@@ -172,27 +342,39 @@ export async function notifyJobDone(chatId: number, jobId: string): Promise<void
let msg = `✅ Fertig: ${done.length} Bild(er)${failed.length ? `, ${failed.length} fehlgeschlagen` : ''}.`; let msg = `✅ Fertig: ${done.length} Bild(er)${failed.length ? `, ${failed.length} fehlgeschlagen` : ''}.`;
if (delivered) msg += `\n📁 ${delivered} in Picdrop-Galerie ausgeliefert.`; if (delivered) msg += `\n📁 ${delivered} in Picdrop-Galerie ausgeliefert.`;
if (BASE) msg += `\n🔗 Bibliothek: ${BASE}/bibliothek`; if (BASE) msg += `\n🔗 Bibliothek: ${BASE}/bibliothek`;
await b.api.sendMessage(chatId, msg); if (done.length) msg += `\n♻️ Weiterbearbeiten? Schick ein Ergebnis einfach zurück.`;
await b.api.sendMessage(chatId, msg, { reply_markup: mainKeyboard() });
} }
// --- Handler ---------------------------------------------------------------- // --- Handler ----------------------------------------------------------------
function register(b: Bot) { function register(b: Bot) {
b.command('start', async (ctx) => { const sendRecipeMenu = async (ctx: any) => {
const link = await linkedUser(ctx.chat.id);
if (link) return ctx.reply('👋 Klarbild-Bot ist verbunden. Leite mir Bilder weiter (als *Datei* für volle Qualität), dann wähle ein Rezept. /help für mehr.', { parse_mode: 'Markdown' });
return ctx.reply('👋 Willkommen bei Klarbild. Zum Koppeln bitte den *Kopplungscode* aus dem Admin-Bereich hier eingeben.', { parse_mode: 'Markdown' });
});
b.command('help', (ctx) => ctx.reply(
'So gehts:\n• Bilder weiterleiten (einzeln oder als Album) — am besten als *Datei*.\n• Rezept wählen.\n• Ich melde mich einmal, wenn alles fertig ist — mit den Ergebnissen und Links.\n\nBefehle: /rezepte (Standard setzen) · /status · /start',
{ parse_mode: 'Markdown' }));
b.command('rezepte', async (ctx) => {
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 10'); const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 10');
const kb = new InlineKeyboard(); const kb = new InlineKeyboard();
recipes.forEach((r, i) => { kb.text(r.name, `d:${r.id}`); if (i % 2 === 1) kb.row(); }); recipes.forEach((r, i) => { kb.text(r.name, `d:${r.id}`); if (i % 2 === 1) kb.row(); });
return ctx.reply('Standard-Rezept für diesen Chat wählen:', { reply_markup: kb }); return ctx.reply('Welches Rezept soll für neue Bilder standardmäßig gelten?', { reply_markup: kb });
};
b.command('start', async (ctx) => {
const link = await linkedUser(ctx.chat.id);
if (link) return ctx.reply('👋 Klarbild ist verbunden. Schick mir Bilder zum Bearbeiten/Kombinieren, oder tippe unten auf „✨ Neues Bild".', { reply_markup: mainKeyboard() });
return ctx.reply('👋 Willkommen bei Klarbild. Zum Koppeln bitte den *Kopplungscode* aus dem Admin-Bereich hier eingeben.', { parse_mode: 'Markdown' });
});
const sendHelp = (ctx: any) => ctx.reply(HELP_TEXT, { parse_mode: 'Markdown', reply_markup: mainKeyboard() });
b.command('help', sendHelp);
b.command('hilfe', sendHelp);
b.command('neu', async (ctx) => {
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
const text = (ctx.match || '').toString().trim();
if (!text) { awaitingGenerate.add(ctx.chat.id); return ctx.reply('Beschreibe kurz dein Bild — was soll entstehen?'); }
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat.id, text, b2);
});
b.command('rezepte', async (ctx) => {
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
return sendRecipeMenu(ctx);
}); });
b.command('status', async (ctx) => { b.command('status', async (ctx) => {
@@ -205,7 +387,8 @@ function register(b: Bot) {
const onImage = async (ctx: any, fileId: string, name: string, quality: 'original' | 'compressed') => { const onImage = async (ctx: any, fileId: string, name: string, quality: 'original' | 'compressed') => {
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('⛔️ Nicht gekoppelt. Bitte Kopplungscode aus dem Admin eingeben.'); if (!(await linkedUser(ctx.chat.id))) return ctx.reply('⛔️ Nicht gekoppelt. Bitte Kopplungscode aus dem Admin eingeben.');
await addToDraft(ctx.chat.id, ctx.message?.media_group_id || null, { file_id: fileId, name, quality }); await addToDraft(ctx.chat.id, ctx.message?.media_group_id || null,
{ file_id: fileId, name, quality }, ctx.message?.caption || null);
}; };
b.on('message:photo', async (ctx) => { b.on('message:photo', async (ctx) => {
const p = ctx.message.photo[ctx.message.photo.length - 1]; const p = ctx.message.photo[ctx.message.photo.length - 1];
@@ -219,16 +402,73 @@ function register(b: Bot) {
b.on('message:text', async (ctx) => { b.on('message:text', async (ctx) => {
if (ctx.message.text.startsWith('/')) return; if (ctx.message.text.startsWith('/')) return;
if (await linkedUser(ctx.chat.id)) return; // gekoppelt: Text ignorieren const text = ctx.message.text.trim();
if (await tryPair(ctx.chat.id, ctx.message.text)) { const linked = await linkedUser(ctx.chat.id);
return ctx.reply('✅ Verbunden! Leite mir jetzt Bilder weiter. /help für mehr.'); if (linked) {
// Dauertastatur-Knöpfe
if (text === BTN_HELP) return sendHelp(ctx);
if (text === BTN_RECIPES) return sendRecipeMenu(ctx);
if (text === BTN_NEW) { awaitingGenerate.add(ctx.chat.id); return ctx.reply('Beschreibe kurz dein Bild — was soll entstehen?'); }
// Wartet ein Kombinieren-Draft auf die Beschreibung? → Text als Beschreibung nehmen.
const waiting = await one<{ id: string }>(
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_compose_text'
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat.id]);
if (waiting) {
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat.id, waiting.id, text, b2);
return;
}
// Nutzer hat „Neues Bild" angetippt und tippt jetzt die Beschreibung.
if (awaitingGenerate.has(ctx.chat.id)) {
awaitingGenerate.delete(ctx.chat.id);
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat.id, text, b2);
return;
}
// Sonst: Freitext als Angebot zum Neu-Erzeugen anbieten.
const kb = new InlineKeyboard().text('✨ Ja, Bild daraus erzeugen', 'g:x');
pendingGenerate.set(ctx.chat.id, text);
return ctx.reply('Soll ich daraus ein neues Bild erzeugen?', { reply_markup: kb });
}
if (await tryPair(ctx.chat.id, text)) {
return ctx.reply('✅ Verbunden! Schick mir Bilder oder tippe unten auf „✨ Neues Bild".', { reply_markup: mainKeyboard() });
} }
return ctx.reply('Code ungültig oder abgelaufen. Neuen Code im Admin erzeugen.'); return ctx.reply('Code ungültig oder abgelaufen. Neuen Code im Admin erzeugen.');
}); });
b.on('callback_query:data', async (ctx) => { b.on('callback_query:data', async (ctx) => {
const [kind, recipeId] = ctx.callbackQuery.data.split(':'); const [kind, recipeId, extra] = ctx.callbackQuery.data.split(':');
await ctx.answerCallbackQuery(); await ctx.answerCallbackQuery();
// Auch Knöpfe brauchen eine aktive Kopplung — sonst wirkt ein alter Chat weiter.
if (!(await linkedUser(ctx.chat!.id))) return ctx.editMessageText('⛔️ Nicht (mehr) gekoppelt. Bitte neuen Kopplungscode aus dem Admin eingeben.');
if (kind === 'p') { // Druckbogen ohne KI
const draft = await one<{ id: string }>(
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status IN ('awaiting_recipe','awaiting_print')
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]);
if (!draft) return ctx.editMessageText('Kein offener Bild-Stapel gefunden — bitte Bilder neu senden.');
if (recipeId === 'menu') {
await query(`UPDATE telegram_drafts SET status='awaiting_print' WHERE id=$1`, [draft.id]);
const kb = new InlineKeyboard();
PRINT_SIZES.forEach((id, i) => {
const s = photoById(id); if (!s) return;
kb.text(labelMm(s.w, s.h), `p:${id}`); if (i % 2 === 1) kb.row();
});
return ctx.editMessageText('📐 Welches Endmaß soll das Bild haben?', { reply_markup: kb });
}
if (extra === undefined) {
const s = photoById(recipeId);
if (!s) return ctx.editMessageText('Unbekanntes Maß.');
const kb = new InlineKeyboard();
PRINT_COUNTS.forEach((n, i) => {
kb.text(n ? `${n}×` : 'Bogen füllen', `p:${recipeId}:${n}`); if (i % 2 === 1) kb.row();
});
return ctx.editMessageText(`📐 ${labelMm(s.w, s.h)} — wie oft auf den A4-Bogen?`, { reply_markup: kb });
}
await ctx.editMessageText('Alles klar — Bogen wird gebaut.');
const b2 = await getBot();
if (b2) await dispatchPrint(ctx.chat!.id, draft.id, recipeId, Number(extra) || 0, b2)
.catch((e) => b2.api.sendMessage(ctx.chat!.id, `❌ Druckbogen fehlgeschlagen: ${e?.message || e}`));
return;
}
if (kind === 'd') { // Standard-Rezept setzen if (kind === 'd') { // Standard-Rezept setzen
await query(`UPDATE telegram_links SET default_recipe_id=$2 WHERE chat_id=$1`, [ctx.chat!.id, recipeId]); await query(`UPDATE telegram_links SET default_recipe_id=$2 WHERE chat_id=$1`, [ctx.chat!.id, recipeId]);
return ctx.editMessageText('Standard-Rezept gesetzt.'); return ctx.editMessageText('Standard-Rezept gesetzt.');
@@ -241,5 +481,25 @@ function register(b: Bot) {
await ctx.editMessageText('Alles klar, los gehts.'); await ctx.editMessageText('Alles klar, los gehts.');
const b2 = await getBot(); if (b2) await dispatchDraft(ctx.chat!.id, draft.id, recipeId, b2); const b2 = await getBot(); if (b2) await dispatchDraft(ctx.chat!.id, draft.id, recipeId, b2);
} }
if (kind === 'c') { // Kombinieren gewählt
const draft = await one<{ id: string; caption: string | null }>(
`SELECT id, caption FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_recipe'
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]);
if (!draft) return ctx.editMessageText('Kein offener Bild-Stapel gefunden — bitte Bilder neu senden.');
if (draft.caption?.trim()) {
await ctx.editMessageText(`Alles klar — kombiniere mit: „${draft.caption.trim()}".`);
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat!.id, draft.id, draft.caption.trim(), b2);
} else {
await query(`UPDATE telegram_drafts SET status='awaiting_compose_text' WHERE id=$1`, [draft.id]);
await ctx.editMessageText('Beschreibe kurz, was entstehen soll (z. B. „das Bild, aber mit unserer Hündin Frieda"):');
}
}
if (kind === 'g') { // Freitext → neues Bild
const text = pendingGenerate.get(ctx.chat!.id);
if (!text) return ctx.editMessageText('Text nicht mehr vorhanden — bitte erneut senden oder /neu nutzen.');
pendingGenerate.delete(ctx.chat!.id);
await ctx.editMessageText('Alles klar, erzeuge ein neues Bild …');
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat!.id, text, b2);
}
}); });
} }
+17 -2
View File
@@ -1,14 +1,29 @@
import { defineMiddleware } from 'astro:middleware'; import { defineMiddleware } from 'astro:middleware';
import { ensureInit } from './lib/init'; import { ensureInit } from './lib/init';
import { readSession } from './lib/auth'; import { readSession } from './lib/auth';
import { one } from './lib/db';
const PUBLIC_PATHS = [/^\/login/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/]; const PUBLIC_PATHS = [/^\/login/, /^\/vorstellung/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/, /^\/llms\.txt/];
/** API-Token (Authorization: Bearer …) → synthetischer Admin-Nutzer für /api/*. */
async function tokenUser(header: string | null): Promise<any | null> {
const m = /^Bearer\s+(.+)$/i.exec(header || '');
if (!m) return null;
const s = await one<{ api_token: string | null }>('SELECT api_token FROM settings WHERE id=1');
if (!s?.api_token || s.api_token !== m[1].trim()) return null;
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1`);
return { uid: admin?.id || null, role: 'admin', name: 'API', username: 'api' };
}
export const onRequest = defineMiddleware(async (ctx, next) => { export const onRequest = defineMiddleware(async (ctx, next) => {
// Health/Webhook dürfen laufen, auch wenn Init noch hakt — sonst blockiert nichts. // Health/Webhook dürfen laufen, auch wenn Init noch hakt — sonst blockiert nichts.
try { await ensureInit(); } catch (e) { if (ctx.url.pathname !== '/api/health') throw e; } try { await ensureInit(); } catch (e) { if (ctx.url.pathname !== '/api/health') throw e; }
const user = readSession(ctx.request.headers.get('cookie')); let user = readSession(ctx.request.headers.get('cookie'));
// Programmatischer Zugriff per API-Token (nur für /api/*, außer Admin-Bereich).
if (!user && ctx.url.pathname.startsWith('/api/') && !ctx.url.pathname.startsWith('/api/admin')) {
user = await tokenUser(ctx.request.headers.get('authorization'));
}
ctx.locals.user = user; ctx.locals.user = user;
const path = ctx.url.pathname; const path = ctx.url.pathname;
+190
View File
@@ -0,0 +1,190 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="Anleitung · Klarbild">
<div class="howto">
<div class="hkopf">
<h1>So funktioniert Klarbild</h1>
<div class="hkopf-akt">
<a class="druck" href="/changelog">Changelog</a>
<button class="druck" onclick="window.print()">Als PDF / drucken</button>
</div>
</div>
<p class="lead">Aus Screenshots und Fundstücken werden druckfertige Bilder — bereinigt, freigestellt,
im richtigen Format, optional direkt bei Picdrop. Alles geht über die Website und über Telegram.</p>
<section>
<h2>1 · Bild bearbeiten</h2>
<ol>
<li>Im <b>Studio</b> oben <b>Bearbeiten</b> wählen.</li>
<li>Screenshots hochladen — ziehen, mit ⌘/Strg + V einfügen oder tippen (bis zu 100 auf einmal).</li>
<li>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).</li>
<li>Beim Format „<b>Zuschneiden</b>" oder „<b>Erweitern</b>" wählen — Erweitern erzeugt mehr Szene rund ums
Motiv (Outpainting), ideal für The Frame.</li>
<li><b>Qualität</b> und Ziel (<b>Bibliothek</b>, <b>Picdrop</b> oder beides) wählen, dann <b>Loslegen</b>.</li>
</ol>
</section>
<section>
<h2>2 · Mehrere Bilder kombinieren</h2>
<p>Modus <b>Kombinieren</b>: mindestens zwei Bilder hochladen und beschreiben, was entstehen soll —
z. B. „das Poolbild, aber mit unserer Hündin Frieda am Beckenrand". Das erste Bild ist die Leitszene,
weitere liefern Personen oder Motive. Es entsteht ein neues, zusammengesetztes Bild.</p>
</section>
<section>
<h2>3 · Neues Bild aus Text</h2>
<p>Modus <b>Neu erzeugen</b>: ganz ohne Vorlage, nur aus einer Beschreibung. Je genauer der Text,
desto besser das Ergebnis.</p>
</section>
<section>
<h2>4 · Bibliothek, Ordner, Alternativen & Weiterbearbeiten</h2>
<p>Alle Ergebnisse landen in der <b>Bibliothek</b>. Über die Ordner-Leiste oben lässt sich nach Ordner
filtern; jedem Ordner kann eine eigene Picdrop-Galerie zugeordnet werden (Pfeil am Ordner = eigene
Galerie hinterlegt).</p>
<ul>
<li><b>+ Alternative</b>: erzeugt eine weitere Fassung desselben Bildes. Die Fassungen liegen gruppiert
beieinander und lassen sich per Miniaturleiste umschalten (Badge „Fassung 2/3").</li>
<li><b>Weiterbearbeiten</b>: übernimmt ein fertiges Bild als neue Vorlage ins Studio — dort erneut
bearbeiten, ins nächste Format bringen oder im Modus Kombinieren mit weiteren Bildern mischen.</li>
<li><b>Vorher/Nachher</b>: zeigt den Vergleich Vorlage ↔ Ergebnis (bei reinen Text-Bildern entfällt das).</li>
<li><b>Ordner verwalten</b>: Ordner-Chip antippen, dann ✎ zum Umbenennen (inkl. Galerie) oder ✕ zum Löschen
(die Bilder bleiben erhalten).</li>
<li><b>Mehrere auf einmal</b>: Bilder markieren, dann als <b>ZIP laden</b> oder gesammelt <b>in einen Ordner</b>
verschieben.</li>
</ul>
</section>
<section>
<h2>5 · Drucken — exakte Maße und Schnittmarken (ohne KI)</h2>
<p class="lead">Der Reiter <b>Drucken</b> ist der Fotolabor-Teil von Klarbild: kein Modell, keine Kosten,
keine Wartezeit. Nur Zuschnitt, Skalierung und Geometrie — vom biometrischen Passbild bis zum
30 × 40-Poster.</p>
<ol>
<li>Bilder hochladen (ziehen, ⌘/Strg + V, oder <b>Aus Bibliothek</b> ein fertiges Ergebnis holen).</li>
<li>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 A6A2 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).</li>
<li><b>Wenn das Bild nicht zum Format passt</b>, entscheidest du je Bild:
<b>Zuschneiden</b> (das Format wird ausgefüllt, außen fehlt etwas — Standard) oder
<b>Rand lassen</b> (das ganze Bild bleibt sichtbar, außen steht die <b>Randfarbe</b> —
weiß, schwarz, papierfarben oder frei gewählt). <b>Verzerrt wird nie.</b></li>
<li>Auf das Vorschaubild tippen → <b>Zuschnitt</b>: schieben und zoomen. Das Seitenverhältnis bleibt
fest am Zielformat, verzerrt wird nie.</li>
<li><b>Anzahl</b> je Bild setzen — so entsteht der Passbild- oder Kita-Satz.</li>
<li><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.</li>
<li><b>Papierformat</b> wählen: DIN A6A2 inklusive <b>A3+ (329 × 483 mm)</b>, Fotopapier-Zuschnitte
9 × 13 bis 20 × 30, US Letter/Legal — oder ein eigenes Papiermaß.</li>
<li><b>Schnitthilfen</b>: <b>Eckmarken</b> (feine Marken außerhalb des Endformats, wie in Photoshop
und InDesign) oder <b>durchgehende Linien</b> über den ganzen Bogen fürs Schneidelineal.
Die durchgehenden Linien laufen nie durch ein anderes Motiv.</li>
<li><b>Ist das Endmaß größer als der Bogen</b> — etwa 20 × 30 cm auf DIN-A4-Fotopapier —
erscheint direkt am Bild die Frage, wie es weitergehen soll:
<b>Überstehen</b> legt das Bild mittig aufs ganze Blatt, der Überstand wird abgeschnitten
(bei 20 × 30 auf A4 fehlen genau 3 mm in der Höhe, und genau das steht auch dabei), oder
<b>Posterdruck</b> verteilt es auf mehrere Blätter zum Zusammenkleben.</li>
<li><b>Druck-PDF erzeugen</b>. Die PDF-Seite hat exakt das Bogenmaß.</li>
</ol>
<p class="hinweis"><b>Wichtig beim Drucken:</b> im Druckdialog „Tatsächliche Größe" bzw. „100 %" wählen —
<b>nicht</b> „an Seitengröße anpassen". Sonst stimmt das Maß nicht mehr. Wer randlos drucken kann,
setzt den Rand auf 0; sonst 35 mm, sonst schneidet der Drucker die Marken weg.</p>
<ul>
<li><b>Beschnittzugabe</b> (010 mm): das Bild ragt über die Schnittkante hinaus, damit bei leicht
schiefem Schnitt kein weißer Rand entsteht. Der Abstand zwischen zwei Bildern wächst automatisch mit.</li>
<li><b>Posterdruck</b>: Beim Verteilen auf mehrere Blätter überlappen sich die Kacheln um einen
<b>Klebefalz</b> (Standard 10 mm, frei einstellbar). Der Falz wird auf beiden Nachbarblättern
gedruckt und mit einer gestrichelten Linie markiert — beim Zusammensetzen legt man das eine
Blatt entlang dieser Linie aufs andere. Jedes Blatt trägt oben seine Nummer samt Reihe und
Spalte. 30 × 40 cm auf A4 ergibt 2 × 2 Blätter.</li>
<li><b>Beschriftung</b>: Unter dem Bogen steht standardmäßig eine kleine Zeile mit Maßen, dpi und
dem Druckhinweis. Sie lässt sich <b>ausschalten</b>, mit <b>eigenem Text</b> versehen (z. B.
„Phoenix · August 2026") und an eine von sechs Stellen legen — oben oder unten, jeweils links,
mittig oder rechts. Ist an der gewählten Kante kein Platz frei, entfällt sie automatisch;
über ein Bild wird sie nie gedruckt.</li>
<li><b>Einzeln herunterladen</b>: ein Bild exakt auf Maß als PNG/JPG mit dpi-Metadaten — ohne Bogen.</li>
<li><b>Auflösungswarnung</b>: reicht die Quelle nicht für die gewählte dpi, steht es rot unter dem Bild.</li>
<li><b>Vorlagen</b>: die ganze Bogen-Einstellung (Papier, Marken, Formate, Stückzahlen) speichern.
Mitgeliefert sind <b>Kita-Satz (A4)</b> (1 × 13 × 18, 2 × 9 × 13, 8 × Passbild — das ist
bewusst mehr, als auf ein Blatt geht; die Vorschau sagt dir, wie viele Bogen es werden),
<b>Schulsatz klein (A4)</b>, <b>Passbildbogen 35 × 45 (A4)</b> und
<b>2 × 10 × 7,5 auf Fotopapier 10 × 15</b>. Lädst du eine mehrformatige Vorlage bei nur
<i>einem</i> Bild, wird das Bild automatisch für jedes Format übernommen — ein Klick,
fertiger Satz.</li>
<li><b>Randprofile</b>: <b>Randlos</b> (0 mm), <b>Standard</b> (5 mm) oder <b>Sicher</b> (10 mm)
per Knopf, der genaue Wert bleibt frei eintragbar.</li>
<li><b>Wohin?</b>: der fertige Bogen kann zusätzlich an Picdrop, aufs NAS oder an ein
Zusatzziel gelegt werden — wie die Bilder aus dem Studio.</li>
<li><b>Automatische Anordnung</b>: gleich große Bilder ergeben ein sauberes Raster; bei gemischten
Größen werden die kleinen automatisch in die Restflächen neben den großen gesetzt.</li>
</ul>
<p class="mini">Auch per Telegram: Bild schicken → „🖨 Drucken" → Maß und Anzahl antippen → PDF zurück.
Am Handy steht die Vorschau oben und „Druck-PDF erzeugen" liegt als fester Balken griffbereit
über der Navigation; am Rechner arbeitest du links und siehst rechts, was dabei herauskommt.
Wer das Werkzeug jemandem zeigen will, ohne dass etwas hochgeladen werden kann, schickt den
Link auf <a href="/vorstellung">die Vorstellungsseite</a>.</p>
</section>
<section>
<h2>6 · Alles per Telegram — ohne Befehle</h2>
<p>Einmalig koppeln: im Admin einen Code erzeugen und im Chat mit dem Bot eingeben. Danach läuft alles über
die Knöpfe unten im Chat — kein Tippen von Befehlen nötig.</p>
<ul>
<li><b>Bearbeiten:</b> Bild(er) schicken (am besten als <i>Datei</i> für volle Qualität) → unten das
Rezept antippen.</li>
<li><b>Kombinieren:</b> zwei oder mehr Bilder schicken, ins Bild-Textfeld eine Beschreibung wie
„mit unserer Hündin Frieda" — dann „🔀 Kombinieren" antippen. (Ohne Beschreibung fragt der Bot nach.)</li>
<li><b>Neu erzeugen:</b> unten auf „✨ Neues Bild" tippen und beschreiben, was entstehen soll.</li>
<li><b>Weiterbearbeiten:</b> ein fertiges Bild vom Bot einfach wieder zurückschicken.</li>
<li><b>Druckbogen (ohne KI):</b> Bild schicken → „📐 Druckbogen" → Endmaß und Anzahl antippen.
Zurück kommt ein A4-PDF mit Schnittmarken in 100-%-Größe.</li>
<li>Über „❓ Hilfe" gibt es die Kurzanleitung, über „📁 Rezept wählen" das Standard-Rezept.</li>
</ul>
<p class="mini">Für Profis gibt es zusätzlich das „/"-Befehlsmenü (/neu, /rezepte, /status) — nötig ist es nicht.</p>
</section>
<section>
<h2>7 · Sicherung &amp; sauber neu starten (Admin)</h2>
<p>Im Admin-Bereich lässt sich eine <b>zweite Sicherung aufs NAS</b> einrichten (SFTP/FTPS, auch über
Tailscale) — jedes fertige Bild wird dann zusätzlich gespiegelt und in der Bibliothek mit <b>„NAS ✓"</b>
markiert. Mit <b>„Alle vorhandenen Bilder aufs NAS sichern"</b> lassen sich auch ältere Bilder nachsichern.</p>
<p>Zum Aufräumen gibt es <b>„Bibliothek &amp; Aufträge zurücksetzen"</b>: löscht alle Bilder, Aufträge und Ordner
(Nutzer, Einstellungen, Modelle und Rezepte bleiben) — mit Sicherheitsabfrage. Am besten vorher einmal
aufs NAS sichern.</p>
</section>
<section>
<h2>Tipps</h2>
<ul>
<li>Für Druck immer 300 dpi und ein cm-Format wählen — die Pixelmaße stehen direkt dabei.</li>
<li>In Telegram Bilder als <i>Datei</i> senden, nicht als Foto — sonst komprimiert Telegram.</li>
<li>Sticker: Freistellen + Kontur zusammen wählen.</li>
</ul>
</section>
</div>
<style>
.howto{max-width:760px;margin:0 auto;}
.hkopf{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
.howto h1{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:6px 0 4px;}
.hkopf-akt{display:flex;gap:8px;align-items:center;}
.druck{background:var(--card);border:1px solid var(--line);border-radius:999px;padding:8px 15px;cursor:pointer;font-family:inherit;font-size:.85rem;color:var(--ink);text-decoration:none;}
.lead{color:var(--soft);line-height:1.6;margin:4px 0 20px;}
.howto section{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;}
.howto h2{font-size:1.05rem;margin:0 0 10px;}
.howto ol,.howto ul{margin:0;padding-left:20px;line-height:1.65;}
.howto li{margin-bottom:6px;}
.howto code{font-family:var(--font-mono);font-size:.85em;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
.howto .mini{font-size:.82rem;color:var(--soft);margin:8px 0 0;}
.howto .hinweis{background:var(--accent-bg);border-left:3px solid var(--accent);padding:10px 13px;margin:12px 0;font-size:.92rem;line-height:1.55;}
.howto code{font-family:var(--font-mono);font-size:.85em;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
@media print{.topbar,.tabbar,.druck{display:none !important;}.howto section{break-inside:avoid;}}
</style>
</Base>
+61
View File
@@ -0,0 +1,61 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
import { encrypt } from '../../../lib/crypto';
import { loadTargetConfig, testConnection } from '../../../lib/picdrop';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async () => {
const rows = await query<any>(`SELECT id, name, protocol, host, port, username, base_path, is_backup, metadata_sidecar,
(password_enc IS NOT NULL) AS password_set FROM delivery_targets ORDER BY name`);
return json({ targets: rows });
};
// POST: anlegen | { action:'test', id } | { action:'backup_all' }
export const POST: APIRoute = async ({ request }) => {
const b = await request.json();
if (b.action === 'test') {
const cfg = await loadTargetConfig(b.id);
if (!cfg) return json({ ok: false, message: 'Ziel unvollständig konfiguriert.' });
return json(await testConnection(cfg));
}
if (b.action === 'backup_all') {
const { backupAll } = await import('../../../lib/backup');
return json(await backupAll());
}
if (!b.name?.trim()) return json({ error: 'Name fehlt.' }, 400);
const row = await one<any>(
`INSERT INTO delivery_targets (name, protocol, host, port, username, password_enc, base_path, is_backup, metadata_sidecar)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
[b.name.trim(), b.protocol || 'sftp', b.host || null, b.port || null, b.username || null,
b.password ? encrypt(String(b.password)) : null, b.base_path || null, !!b.is_backup, !!b.metadata_sidecar]);
return json({ id: row!.id });
};
// PATCH: bearbeiten
export const PATCH: APIRoute = async ({ request }) => {
const b = await request.json();
if (!b.id) return json({ error: 'Keine ID.' }, 400);
const sets: string[] = []; const args: any[] = [];
const set = (c: string, v: any) => { args.push(v); sets.push(`${c}=$${args.length}`); };
for (const c of ['name', 'protocol', 'host', 'port', 'username', 'base_path']) {
if (c in b) set(c, b[c] === '' ? null : b[c]);
}
if ('is_backup' in b) set('is_backup', !!b.is_backup);
if ('metadata_sidecar' in b) set('metadata_sidecar', !!b.metadata_sidecar);
if (b.password) set('password_enc', encrypt(String(b.password)));
if (!sets.length) return json({ ok: true });
args.push(b.id);
await query(`UPDATE delivery_targets SET ${sets.join(',')} WHERE id=$${args.length}`, args);
return json({ ok: true });
};
export const DELETE: APIRoute = async ({ request, url }) => {
let id = url.searchParams.get('id');
if (!id) { try { id = (await request.json())?.id; } catch { /* egal */ } }
if (!id) return json({ error: 'Keine ID.' }, 400);
await query('DELETE FROM delivery_targets WHERE id=$1', [id]);
return json({ ok: true });
};
+4
View File
@@ -31,6 +31,10 @@ export const POST: APIRoute = async ({ request }) => {
await query('UPDATE models SET is_default=false'); await query('UPDATE models SET is_default=true WHERE id=$1', [b.id]); await query('UPDATE models SET is_default=false'); await query('UPDATE models SET is_default=true WHERE id=$1', [b.id]);
return json({ ok: true }); return json({ ok: true });
} }
if (b.action === 'nsfw' && b.id) {
await query('UPDATE models SET nsfw = NOT nsfw WHERE id=$1', [b.id]);
return json({ ok: true });
}
if (!b.model_id || !b.label) return json({ error: 'model_id + label nötig.' }, 400); if (!b.model_id || !b.label) return json({ error: 'model_id + label nötig.' }, 400);
const existing = await one<{ id: string }>('SELECT id FROM models WHERE model_id=$1', [b.model_id]); const existing = await one<{ id: string }>('SELECT id FROM models WHERE model_id=$1', [b.model_id]);
if (existing) { if (existing) {
+56
View File
@@ -0,0 +1,56 @@
import type { APIRoute } from 'astro';
import net from 'node:net';
import dns from 'node:dns/promises';
import { one } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** TCP-Verbindungstest aus dem Container heraus (mit Timeout). */
function tcpProbe(host: string, port: number, timeoutMs = 6000): Promise<{ ok: boolean; ms: number; error?: string }> {
return new Promise((resolve) => {
const start = Date.now();
const sock = new net.Socket();
let done = false;
const finish = (ok: boolean, error?: string) => {
if (done) return; done = true;
try { sock.destroy(); } catch { /* egal */ }
resolve({ ok, ms: Date.now() - start, error });
};
sock.setTimeout(timeoutMs);
sock.once('connect', () => finish(true));
sock.once('timeout', () => finish(false, 'Timeout — keine Antwort (Firewall/Route?)'));
sock.once('error', (e: any) => finish(false, e?.code || e?.message || 'Verbindungsfehler'));
sock.connect(port, host);
});
}
/** Prüft, ob ein Host:Port aus dem Container erreichbar ist. Body {host,port} oder
* ohne Angabe der konfigurierte NAS-Host. Testet zusätzlich DNS-Auflösung. */
export const POST: APIRoute = async ({ request }) => {
const b = await request.json().catch(() => ({} as any));
let host = (b.host || '').trim();
let port = Number(b.port) || 0;
if (!host) {
const s = await one<{ nas_host: string | null; nas_port: number | null; nas_protocol: string | null }>(
'SELECT nas_host, nas_port, nas_protocol FROM settings WHERE id=1');
host = (s?.nas_host || '').trim();
port = s?.nas_port || (s?.nas_protocol === 'ftps' ? 21 : 22);
}
if (!host) return json({ error: 'Kein Host angegeben.' }, 400);
if (!port) port = 22;
const out: any = { host, port };
// DNS: nur wenn kein reines IP.
const isIp = net.isIP(host) !== 0;
if (!isIp) {
try { const a = await dns.lookup(host); out.resolve = { ok: true, address: a.address }; }
catch (e: any) { out.resolve = { ok: false, error: e?.code || 'DNS fehlgeschlagen' }; }
} else {
out.resolve = { ok: true, address: host, note: 'IP-Adresse' };
}
out.tcp = await tcpProbe(host, port);
out.reachable = out.tcp.ok;
return json(out);
};
+43
View File
@@ -0,0 +1,43 @@
import type { APIRoute } from 'astro';
import posixpath from 'node:path/posix';
import { loadConfig, cleanupTmp } from '../../../lib/picdrop';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** Diagnose: listet einen Picdrop-Ordner (Standard: Basisordner). Body { path? }
* Aufräumen: Body { action:'cleanup_tmp', gallery } entfernt liegengebliebene .tmp-Dateien. */
export const POST: APIRoute = async ({ request }) => {
const b = await request.json().catch(() => ({} as any));
const cfg = await loadConfig();
if (!cfg) return json({ error: 'Picdrop nicht konfiguriert.' }, 400);
if (b.action === 'cleanup_tmp') {
try {
const removed = await cleanupTmp(cfg, b.gallery || '');
return json({ cleaned: removed });
} catch (e: any) { return json({ error: e?.message || 'Aufräumen fehlgeschlagen.' }, 500); }
}
const path = b.path || cfg.basePath || '/';
try {
if (cfg.protocol === 'sftp') {
const SftpClient = (await import('ssh2-sftp-client')).default;
const c = new SftpClient();
await c.connect({ host: cfg.host, port: cfg.port, username: cfg.user, password: cfg.password, readyTimeout: 15000 });
try {
const list = await c.list(path);
return json({ path, entries: list.map((e: any) => ({ name: e.name, type: e.type, size: e.size })) });
} finally { await c.end(); }
} else {
const { Client } = await import('basic-ftp');
const c = new Client(15000);
await c.access({ host: cfg.host, port: cfg.port, user: cfg.user, password: cfg.password, secure: true });
try {
const list = await c.list(path);
return json({ path, entries: list.map((e: any) => ({ name: e.name, type: e.type, size: e.size })) });
} finally { c.close(); }
}
} catch (e: any) {
return json({ path, error: e?.message || 'Listing fehlgeschlagen.' });
}
};
+36
View File
@@ -0,0 +1,36 @@
import type { APIRoute } from 'astro';
import { query } from '../../../lib/db';
import { deleteObject } from '../../../lib/storage';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** Setzt Inhalte zurück: löscht alle Bilder/Aufträge/Ordner + zugehörige Dateien.
* Nutzer, Einstellungen, Modelle und Rezepte bleiben erhalten.
* Erfordert { confirm: 'RESET' }. */
export const POST: APIRoute = async ({ request, locals }) => {
if (locals.user?.role !== 'admin') return new Response('Forbidden', { status: 403 });
const b = await request.json().catch(() => ({} as any));
if (b.confirm !== 'RESET') return json({ error: 'Bestätigung fehlt (confirm=RESET).' }, 400);
// Zuerst alle Objekte aus dem Speicher entfernen.
const items = await query<any>('SELECT source_path, source_paths, result_path, thumb_path FROM items');
let objects = 0;
for (const it of items) {
const keys = [it.source_path, it.result_path, it.thumb_path,
...((it.source_paths as string[]) || [])].filter(Boolean);
for (const k of keys) { await deleteObject(k).catch(() => {}); objects++; }
}
// Inhalts-Tabellen leeren (Konfiguration bleibt).
await query('DELETE FROM print_job_items');
await query('DELETE FROM print_jobs');
await query('DELETE FROM share_links');
await query('DELETE FROM telegram_drafts');
await query('DELETE FROM items');
await query('DELETE FROM jobs');
await query('DELETE FROM folders');
return json({ ok: true, deleted_items: items.length, deleted_objects: objects });
};
+27 -2
View File
@@ -1,8 +1,10 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { randomBytes } from 'node:crypto';
import { one, query } from '../../../lib/db'; import { one, query } from '../../../lib/db';
import { encrypt, maskSecret, decrypt } from '../../../lib/crypto'; import { encrypt, maskSecret, decrypt } from '../../../lib/crypto';
export const prerender = false; export const prerender = false;
const randomToken = () => 'klb_' + randomBytes(24).toString('hex');
const json = (b: unknown, s = 200) => const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
@@ -19,6 +21,17 @@ export const GET: APIRoute = async () => {
picdrop_password_set: !!s.picdrop_password_enc, picdrop_password_set: !!s.picdrop_password_enc,
default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency, default_dpi: s.default_dpi, default_crop_mode: s.default_crop_mode, concurrency: s.concurrency,
cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url, cricut_sheet_cm: s.cricut_sheet_cm, monthly_budget: s.monthly_budget, n8n_webhook_url: s.n8n_webhook_url,
// Speicherverwaltung
keep_sources: s.keep_sources, make_thumbnails: s.make_thumbnails, retention_days: s.retention_days,
metadata_sidecar: s.metadata_sidecar, api_token: s.api_token,
picdrop_metadata_sidecar: s.picdrop_metadata_sidecar, nas_metadata_sidecar: s.nas_metadata_sidecar,
output_ext: s.output_ext,
// Rechte / Datenschutz / NSFW
library_visibility: s.library_visibility, anonymous_generations: s.anonymous_generations,
private_allowed: s.private_allowed, allow_nsfw: s.allow_nsfw,
// NAS-Sicherung
nas_enabled: s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
nas_user: s.nas_user, nas_base_path: s.nas_base_path, nas_password_set: !!s.nas_password_enc,
} }); } });
}; };
@@ -29,9 +42,21 @@ export const PATCH: APIRoute = async ({ request }) => {
if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key))); if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key)));
if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password))); if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password)));
if (b.nas_password) set('nas_password_enc', encrypt(String(b.nas_password)));
if (b.generate_api_token) set('api_token', randomToken());
const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled', 'metadata_sidecar',
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'anonymous_generations', 'private_allowed', 'allow_nsfw'];
for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path', for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path',
'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url']) { 'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url',
if (col in b) set(col, b[col] === '' ? null : b[col]); 'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token',
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'output_ext',
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) {
if (col in b) {
const raw = b[col];
const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
set(col, val);
}
} }
if (!sets.length) return json({ ok: true }); if (!sets.length) return json({ ok: true });
await query(`UPDATE settings SET ${sets.join(',')} WHERE id=1`, args); await query(`UPDATE settings SET ${sets.join(',')} WHERE id=1`, args);
+23
View File
@@ -0,0 +1,23 @@
import type { APIRoute } from 'astro';
import { storageStats, runRetention, purgeSources, mirrorAllToNas, rebuildThumbs } from '../../../lib/maintenance';
import { one } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async () => json({ stats: await storageStats() });
// POST { action: 'retention' | 'purge_sources' }
export const POST: APIRoute = async ({ request }) => {
const b = await request.json().catch(() => ({}));
if (b.action === 'purge_sources') return json(await purgeSources());
if (b.action === 'mirror_all') return json(await mirrorAllToNas());
if (b.action === 'rebuild_thumbs') return json(await rebuildThumbs());
if (b.action === 'retention') {
const s = await one<{ retention_days: number | null }>('SELECT retention_days FROM settings WHERE id=1');
if (!s?.retention_days) return json({ error: 'Keine Aufbewahrungsfrist gesetzt.' }, 400);
return json(await runRetention(s.retention_days));
}
return json({ error: 'Unbekannte Aktion.' }, 400);
};
+12
View File
@@ -0,0 +1,12 @@
import type { APIRoute } from 'astro';
import { loadNasConfig, testNas } from '../../../lib/nas';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async () => {
const cfg = await loadNasConfig();
if (!cfg) return json({ ok: false, message: 'NAS nicht vollständig konfiguriert (oder deaktiviert).' });
return json(await testNas(cfg));
};
+10 -2
View File
@@ -7,17 +7,25 @@ const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } }); new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async () => { export const GET: APIRoute = async () => {
const users = await query('SELECT id, username, role, display_name, created_at FROM users ORDER BY username'); const users = await query('SELECT id, username, role, display_name, private_forced, created_at FROM users ORDER BY username');
return json({ users }); return json({ users });
}; };
// POST: neuen Nutzer anlegen ODER Passwort setzen (bei vorhandenem username/id). // POST: neuen Nutzer anlegen ODER Passwort/Attribute setzen (bei vorhandenem username/id).
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
const b = await request.json(); const b = await request.json();
if (b.id && b.password) { if (b.id && b.password) {
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [b.id, await hashPassword(b.password)]); await query('UPDATE users SET password_hash=$2 WHERE id=$1', [b.id, await hashPassword(b.password)]);
return json({ ok: true }); return json({ ok: true });
} }
if (b.id && 'private_forced' in b) {
await query('UPDATE users SET private_forced=$2 WHERE id=$1', [b.id, !!b.private_forced]);
return json({ ok: true });
}
if (b.id && b.role) {
await query(`UPDATE users SET role=$2 WHERE id=$1`, [b.id, b.role === 'admin' ? 'admin' : 'user']);
return json({ ok: true });
}
if (!b.username || !b.password) return json({ error: 'username + password nötig.' }, 400); if (!b.username || !b.password) return json({ error: 'username + password nötig.' }, 400);
const exists = await one('SELECT id FROM users WHERE username=$1', [b.username.trim()]); const exists = await one('SELECT id FROM users WHERE username=$1', [b.username.trim()]);
if (exists) return json({ error: 'Benutzername vergeben.' }, 400); if (exists) return json({ error: 'Benutzername vergeben.' }, 400);
+13
View File
@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro';
import { query } from '../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
// Namen der Zusatzziele fürs Studio-Dropdown (kein Zugang).
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const rows = await query('SELECT id, name FROM delivery_targets ORDER BY name');
return json({ targets: rows });
};
+24
View File
@@ -20,3 +20,27 @@ export const POST: APIRoute = async ({ request, locals }) => {
[b.name.trim(), b.picdrop_gallery || null]); [b.name.trim(), b.picdrop_gallery || null]);
return json({ folder: row }); return json({ folder: row });
}; };
// Ordner umbenennen / Galerie ändern. Body { id, name?, picdrop_gallery? }
export const PATCH: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
if (!b.id) return json({ error: 'Keine ID.' }, 400);
const sets: string[] = []; const args: any[] = [];
if (typeof b.name === 'string' && b.name.trim()) { args.push(b.name.trim()); sets.push(`name=$${args.length}`); }
if ('picdrop_gallery' in b) { args.push(b.picdrop_gallery?.trim() || null); sets.push(`picdrop_gallery=$${args.length}`); }
if (!sets.length) return json({ error: 'Nichts zu ändern.' }, 400);
args.push(b.id);
const row = await one(`UPDATE folders SET ${sets.join(',')} WHERE id=$${args.length} RETURNING *`, args);
return json({ folder: row });
};
// Ordner löschen (Bilder bleiben, verlieren nur die Zuordnung). ?id=…
export const DELETE: APIRoute = async ({ request, url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
let id = url.searchParams.get('id');
if (!id) { try { id = (await request.json())?.id; } catch { /* egal */ } }
if (!id) return json({ error: 'Keine ID.' }, 400);
await query('DELETE FROM folders WHERE id=$1', [id]);
return json({ ok: true });
};
+5 -1
View File
@@ -12,9 +12,13 @@ export const PATCH: APIRoute = async ({ params, request, locals }) => {
const sets: string[] = []; const args: any[] = []; const sets: string[] = []; const args: any[] = [];
if (typeof b.filename === 'string') { args.push(b.filename.trim()); sets.push(`filename=$${args.length}`); } if (typeof b.filename === 'string') { args.push(b.filename.trim()); sets.push(`filename=$${args.length}`); }
if ('folder_id' in b) { args.push(b.folder_id || null); sets.push(`folder_id=$${args.length}`); } if ('folder_id' in b) { args.push(b.folder_id || null); sets.push(`folder_id=$${args.length}`); }
if ('color_tag' in b) {
const t = ['red', 'orange', 'green', 'final'].includes(b.color_tag) ? b.color_tag : null;
args.push(t); sets.push(`color_tag=$${args.length}`);
}
if (!sets.length) return json({ error: 'Nichts zu ändern.' }, 400); if (!sets.length) return json({ error: 'Nichts zu ändern.' }, 400);
args.push(params.id); args.push(params.id);
const row = await one(`UPDATE items SET ${sets.join(',')} WHERE id=$${args.length} RETURNING id, filename, folder_id`, args); const row = await one(`UPDATE items SET ${sets.join(',')} WHERE id=$${args.length} RETURNING id, filename, folder_id, color_tag`, args);
return json({ item: row }); return json({ item: row });
}; };
+29
View File
@@ -0,0 +1,29 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../../lib/db';
import { enqueue } from '../../../../lib/queue';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** Erzeugt eine weitere Fassung (Alternative) zum selben Ursprung, gleiches Rezept. */
export const POST: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const item = await one<any>(
`SELECT id, job_id, source_path, source_paths, filename, variant_of FROM items WHERE id=$1`, [params.id]);
if (!item) return json({ error: 'Nicht gefunden.' }, 404);
// Ohne Quelle & ohne Modellaufruf gibt es nichts zu variieren.
const root = item.variant_of || item.id;
const it = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, source_path, source_paths, filename, source_quality, variant_of)
VALUES ($1, (SELECT COALESCE(max(position),0)+1 FROM items WHERE job_id=$1), 'queued', $2, $3, $4, 'original', $5)
RETURNING id`,
[item.job_id, item.source_path, item.source_paths ? JSON.stringify(item.source_paths) : null,
item.filename, root]);
// Auftrag zählt eine Position mehr und läuft erneut an.
await query(`UPDATE jobs SET total=total+1, status='queued', finished_at=NULL
WHERE id=$1 AND status IN ('done','paused')`, [item.job_id]);
await enqueue({ itemId: it!.id, jobId: item.job_id });
return json({ ok: true, itemId: it!.id });
};
+37 -7
View File
@@ -1,4 +1,5 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import sharp from 'sharp';
import { one } from '../../../../lib/db'; import { one } from '../../../../lib/db';
import { getObject } from '../../../../lib/storage'; import { getObject } from '../../../../lib/storage';
@@ -7,16 +8,45 @@ export const prerender = false;
// Streamt das Ergebnis- (oder Quell-)Bild aus dem Objektspeicher. // Streamt das Ergebnis- (oder Quell-)Bild aus dem Objektspeicher.
export const GET: APIRoute = async ({ params, url, locals }) => { export const GET: APIRoute = async ({ params, url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); if (!locals.user) return new Response('Unauthorized', { status: 401 });
const which = url.searchParams.get('src') === '1' ? 'source_path' : 'result_path'; const q = url.searchParams;
const item = await one<any>(`SELECT ${which} AS path, filename, has_alpha FROM items WHERE id=$1`, [params.id]); const wantThumb = q.get('thumb') === '1';
if (!item?.path) return new Response('Nicht gefunden', { status: 404 }); const wantSrc = q.get('src') === '1';
const item = await one<any>(
`SELECT i.source_path, i.source_paths, i.result_path, i.thumb_path, i.filename, i.has_alpha,
j.created_by, j.private FROM items i JOIN jobs j ON j.id=i.job_id WHERE i.id=$1`, [params.id]);
if (item) {
const isAdmin = locals.user.role === 'admin';
const own = item.created_by === locals.user.uid;
if (!isAdmin && !own) {
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
if (item.private || s?.library_visibility !== 'shared') return new Response('Forbidden', { status: 403 });
}
}
// Quelle: Einzelquelle, sonst erste Kombinier-Quelle (für Vorher/Nachher).
const firstSource = item?.source_path || (Array.isArray(item?.source_paths) ? item.source_paths[0] : null);
const path = wantSrc ? firstSource
: wantThumb ? (item?.thumb_path || item?.result_path)
: item?.result_path;
if (!path) return new Response('Nicht gefunden', { status: 404 });
try { try {
const buf = await getObject(item.path); let buf = await getObject(path);
const download = url.searchParams.get('download') === '1'; const download = q.get('download') === '1';
// Inhaltstyp aus den Magic Bytes ableiten — Ergebnisse können PNG oder JPG sein.
const sniff = (b: Buffer): string =>
b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff ? 'image/jpeg'
: b.length > 12 && b.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp'
: 'image/png';
let contentType = path.endsWith('.webp') ? 'image/webp' : sniff(buf);
// Schnelle Vollbild-Vorschau: nicht-transparente Ergebnisse als JPEG (deutlich kleiner,
// schneller auf Mobil) — reicht fürs „In Fotos sichern". Der PNG-Download bleibt unverändert.
if (q.get('preview') === '1' && !download && !item?.has_alpha) {
try { buf = await sharp(buf, { failOn: 'none' }).jpeg({ quality: 90, mozjpeg: true }).toBuffer(); contentType = 'image/jpeg'; }
catch { /* Fallback: Original */ }
}
return new Response(buf, { return new Response(buf, {
headers: { headers: {
'Content-Type': item.has_alpha ? 'image/png' : 'image/png', 'Content-Type': contentType,
'Cache-Control': 'private, max-age=300', 'Cache-Control': 'private, max-age=600',
...(download ? { 'Content-Disposition': `attachment; filename="${item.filename || 'klarbild.png'}"` } : {}), ...(download ? { 'Content-Disposition': `attachment; filename="${item.filename || 'klarbild.png'}"` } : {}),
}, },
}); });
+26
View File
@@ -0,0 +1,26 @@
import type { APIRoute } from 'astro';
import { randomUUID } from 'node:crypto';
import { one } from '../../../../lib/db';
import { getObject, putObject, sourceKey } from '../../../../lib/storage';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** Kopiert das Ergebnis eines Items in eine neue Quelle, damit es im Studio
* als Vorlage weiterbearbeitet werden kann. */
export const POST: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const it = await one<{ result_path: string | null; filename: string | null }>(
'SELECT result_path, filename FROM items WHERE id=$1', [params.id]);
if (!it?.result_path) return json({ error: 'Kein Ergebnis vorhanden.' }, 404);
try {
const buf = await getObject(it.result_path);
const key = sourceKey(randomUUID(), 'png');
await putObject(key, buf, 'image/png');
const name = (it.filename || 'bild').replace(/\.[^.]+$/, '') + '.png';
return json({ source_path: key, filename: name });
} catch (e: any) {
return json({ error: e?.message || 'Konnte Ergebnis nicht laden.' }, 500);
}
};
+12 -5
View File
@@ -1,5 +1,5 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { query } from '../../../lib/db'; import { one, query } from '../../../lib/db';
export const prerender = false; export const prerender = false;
const json = (b: unknown, s = 200) => const json = (b: unknown, s = 200) =>
@@ -9,11 +9,17 @@ const json = (b: unknown, s = 200) =>
export const GET: APIRoute = async ({ url, locals }) => { export const GET: APIRoute = async ({ url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); if (!locals.user) return new Response('Unauthorized', { status: 401 });
const q = url.searchParams; const q = url.searchParams;
const where: string[] = [`i.status='done'`]; const s = await one<any>('SELECT library_visibility, anonymous_generations FROM settings WHERE id=1');
const isAdmin = locals.user.role === 'admin';
const shared = s?.library_visibility === 'shared';
// Private Aufträge nie in der Bibliothek; Sichtbarkeit nach Einstellung.
const where: string[] = [`i.status='done'`, `j.private=false`];
const args: any[] = []; const args: any[] = [];
const add = (cond: string, val: any) => { args.push(val); where.push(cond.replace('?', `$${args.length}`)); }; const add = (cond: string, val: any) => { args.push(val); where.push(cond.replace('?', `$${args.length}`)); };
if (!isAdmin && !shared) add('j.created_by = ?', locals.user.uid);
if (q.get('folder')) add('i.folder_id = ?', q.get('folder')); if (q.get('folder')) add('i.folder_id = ?', q.get('folder'));
if (q.get('tag')) add('i.color_tag = ?', q.get('tag'));
if (q.get('delivery')) add('i.delivery_status = ?', q.get('delivery')); if (q.get('delivery')) add('i.delivery_status = ?', q.get('delivery'));
if (q.get('by')) add('j.created_by = ?', q.get('by')); if (q.get('by')) add('j.created_by = ?', q.get('by'));
if (q.get('format')) add('i.output_px = ?', q.get('format')); if (q.get('format')) add('i.output_px = ?', q.get('format'));
@@ -23,13 +29,14 @@ export const GET: APIRoute = async ({ url, locals }) => {
} }
const rows = await query( const rows = await query(
`SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.folder_id, `SELECT i.id, i.filename, i.output_px, i.has_alpha, i.result_path, i.thumb_path, i.folder_id, i.color_tag,
i.delivery_status, i.created_at, i.model_used, u.display_name AS by_name, i.delivery_status, i.nas_status, i.prompt_used, i.created_at, i.model_used, i.variant_of, j.mode,
${(s?.anonymous_generations && !isAdmin) ? 'NULL' : 'u.display_name'} AS by_name,
j.recipe_snapshot->'tasks' AS tasks j.recipe_snapshot->'tasks' AS tasks
FROM items i FROM items i
JOIN jobs j ON j.id = i.job_id JOIN jobs j ON j.id = i.job_id
LEFT JOIN users u ON u.id = j.created_by LEFT JOIN users u ON u.id = j.created_by
WHERE ${where.join(' AND ')} WHERE ${where.join(' AND ')}
ORDER BY i.created_at DESC LIMIT 300`, args); ORDER BY i.created_at DESC LIMIT 400`, args);
return json({ items: rows }); return json({ items: rows });
}; };
+50
View File
@@ -0,0 +1,50 @@
import type { APIRoute } from 'astro';
import archiver from 'archiver';
import { query } from '../../../lib/db';
import { getObject } from '../../../lib/storage';
export const prerender = false;
/** Lädt mehrere Ergebnisse als ZIP. Body { itemIds: [...] } */
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json().catch(() => ({} as any));
const ids: string[] = b.itemIds || [];
if (!ids.length) return new Response('Keine Auswahl.', { status: 400 });
const rows = await query<{ id: string; result_path: string | null; filename: string | null }>(
`SELECT id, result_path, filename FROM items WHERE id = ANY($1) AND result_path IS NOT NULL`, [ids]);
if (!rows.length) return new Response('Keine Ergebnisse.', { status: 404 });
const archive = archiver('zip', { zlib: { level: 6 } });
const seen: Record<string, number> = {};
for (const r of rows) {
try {
const buf = await getObject(r.result_path!);
let name = r.filename || `${r.id}.png`;
if (!/\.[a-z0-9]+$/i.test(name)) name += '.png';
// Namenskollisionen vermeiden
if (seen[name] != null) { const n = ++seen[name]; name = name.replace(/(\.[^.]+)$/, `_${n}$1`); }
else seen[name] = 0;
archive.append(buf, { name });
} catch { /* Datei überspringen */ }
}
const done = archive.finalize();
// Archiver-Stream (Node) in einen Web-ReadableStream überführen.
const stream = new ReadableStream({
start(controller) {
archive.on('data', (c: Buffer) => controller.enqueue(new Uint8Array(c)));
archive.on('end', () => controller.close());
archive.on('error', (e: any) => controller.error(e));
done.catch(() => {});
},
});
const stamp = new Date().toISOString().slice(0, 10);
return new Response(stream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="klarbild_${stamp}.zip"`,
},
});
};
+8 -1
View File
@@ -7,8 +7,15 @@ const json = (b: unknown, s = 200) =>
export const GET: APIRoute = async ({ params, locals }) => { export const GET: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); if (!locals.user) return new Response('Unauthorized', { status: 401 });
const job = await one('SELECT * FROM jobs WHERE id=$1', [params.id]); const job = await one<any>('SELECT * FROM jobs WHERE id=$1', [params.id]);
if (!job) return json({ error: 'Nicht gefunden.' }, 404); if (!job) return json({ error: 'Nicht gefunden.' }, 404);
// Zugriff: eigener Auftrag, Admin, oder shared (nicht privat).
const isAdmin = locals.user.role === 'admin';
const own = job.created_by === locals.user.uid;
if (!isAdmin && !own) {
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
if (job.private || s?.library_visibility !== 'shared') return new Response('Forbidden', { status: 403 });
}
const items = await query( const items = await query(
`SELECT id, position, status, filename, output_px, has_alpha, error_message, `SELECT id, position, status, filename, output_px, has_alpha, error_message,
result_path, delivery_status, cost FROM items WHERE job_id=$1 ORDER BY position`, result_path, delivery_status, cost FROM items WHERE job_id=$1 ORDER BY position`,
+13
View File
@@ -34,6 +34,19 @@ export const POST: APIRoute = async ({ params, locals }) => {
for (const it of items) await enqueue({ itemId: it.id, jobId: id }); for (const it of items) await enqueue({ itemId: it.id, jobId: id });
break; break;
} }
case 'delete': {
// Auftrag samt Positionen und deren Objekten entfernen (für alte/fehlgeschlagene Aufträge).
const { deleteObject } = await import('../../../../lib/storage');
const items = await query<any>(
`SELECT source_path, source_paths, result_path, thumb_path FROM items WHERE job_id=$1`, [id]);
for (const it of items) {
const keys = [it.source_path, it.result_path, it.thumb_path, ...((it.source_paths as string[]) || [])].filter(Boolean);
for (const k of keys) await deleteObject(k).catch(() => {});
}
await query(`DELETE FROM items WHERE job_id=$1`, [id]);
await query(`DELETE FROM jobs WHERE id=$1`, [id]);
break;
}
default: default:
return json({ error: 'Unbekannte Aktion.' }, 400); return json({ error: 'Unbekannte Aktion.' }, 400);
} }
+49 -15
View File
@@ -8,18 +8,32 @@ const json = (b: unknown, s = 200) =>
export const GET: APIRoute = async ({ locals }) => { export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); if (!locals.user) return new Response('Unauthorized', { status: 401 });
const s = await one<any>('SELECT library_visibility, anonymous_generations FROM settings WHERE id=1');
const isAdmin = locals.user.role === 'admin';
const shared = s?.library_visibility === 'shared';
const where: string[] = ['j.private=false']; const args: any[] = [];
if (!isAdmin && !shared) { args.push(locals.user.uid); where.push(`j.created_by=$${args.length}`); }
const nameCol = (s?.anonymous_generations && !isAdmin) ? 'NULL' : 'u.display_name';
const rows = await query( const rows = await query(
`SELECT j.*, u.display_name AS by_name FROM jobs j `SELECT j.*, ${nameCol} AS by_name FROM jobs j
LEFT JOIN users u ON u.id=j.created_by ORDER BY j.created_at DESC LIMIT 50`); LEFT JOIN users u ON u.id=j.created_by
WHERE ${where.join(' AND ')} ORDER BY j.created_at DESC LIMIT 50`, args);
return json({ jobs: rows }); return json({ jobs: rows });
}; };
// Body: { recipeId?, recipe?, sources:[{source_path, filename, source_quality?}], origin? } // Body: { recipeId?, recipe?, sources:[{source_path, filename, source_quality?}],
// mode?: 'each'|'compose'|'generate', prompt_text?, origin? }
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json(); const b = await request.json();
const mode: 'each' | 'compose' | 'generate' = ['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each';
const sources: any[] = b.sources || []; const sources: any[] = b.sources || [];
if (!sources.length) return json({ error: 'Keine Bilder.' }, 400); const promptText: string = (b.prompt_text || '').trim();
if (mode === 'each' && !sources.length) return json({ error: 'Keine Bilder.' }, 400);
if (mode === 'compose' && sources.length < 1) return json({ error: 'Mindestens 1 Bild nötig.' }, 400);
if (mode === 'compose' && !promptText) return json({ error: 'Bitte beschreiben, was entstehen soll.' }, 400);
if (mode === 'generate' && !promptText) return json({ error: 'Bitte einen Text eingeben.' }, 400);
let snapshot: any = b.recipe; let snapshot: any = b.recipe;
if (b.recipeId) { if (b.recipeId) {
@@ -27,11 +41,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
if (!r) return json({ error: 'Rezept nicht gefunden.' }, 404); if (!r) return json({ error: 'Rezept nicht gefunden.' }, 404);
snapshot = r; snapshot = r;
} }
if (!snapshot) return json({ error: 'Kein Rezept.' }, 400); snapshot = snapshot || {};
// Rezept-Snapshot einfrieren // Rezept-Snapshot einfrieren
const snap = { const snap = {
tasks: snapshot.tasks || [], tasks: snapshot.tasks || (mode === 'each' ? [] : []),
output_format: snapshot.output_format, output_format: snapshot.output_format,
orientation: snapshot.orientation, orientation: snapshot.orientation,
crop_mode: snapshot.crop_mode || 'crop', crop_mode: snapshot.crop_mode || 'crop',
@@ -39,21 +53,41 @@ export const POST: APIRoute = async ({ request, locals }) => {
contour_mm: snapshot.contour_mm ?? null, contour_mm: snapshot.contour_mm ?? null,
model_key: snapshot.model_key ?? null, model_key: snapshot.model_key ?? null,
custom_instruction: snapshot.custom_instruction ?? null, custom_instruction: snapshot.custom_instruction ?? null,
prompt_text: promptText || null,
delivery: snapshot.delivery || 'library', delivery: snapshot.delivery || 'library',
picdrop_gallery: snapshot.picdrop_gallery ?? null, picdrop_gallery: snapshot.picdrop_gallery ?? null,
delivery_target_id: snapshot.delivery_target_id ?? null,
output_ext: (snapshot.output_ext === 'jpg' || snapshot.output_ext === 'png') ? snapshot.output_ext : null,
}; };
const job = await one<{ id: string }>( // Private Session: nur wenn global erlaubt oder für den Nutzer erzwungen.
`INSERT INTO jobs (created_by, origin, recipe_snapshot, status, total) const acc = await one<any>(`SELECT s.private_allowed, u.private_forced
VALUES ($1,$2,$3,'queued',$4) RETURNING id`, FROM settings s, users u WHERE s.id=1 AND u.id=$1`, [locals.user.uid]);
[locals.user.uid, b.origin || 'web', JSON.stringify(snap), sources.length]); const isPrivate = !!(acc?.private_forced || (acc?.private_allowed && b.private));
if (isPrivate) snap.delivery = 'library'; // kein externes Delivery bei privaten Aufträgen
for (let i = 0; i < sources.length; i++) { const total = (mode === 'each') ? sources.length : 1;
const s = sources[i]; const job = await one<{ id: string }>(
`INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, private)
VALUES ($1,$2,$3,$4,'queued',$5,$6) RETURNING id`,
[locals.user.uid, b.origin || 'web', mode, JSON.stringify(snap), total, isPrivate]);
if (mode === 'each') {
for (let i = 0; i < sources.length; i++) {
const s = sources[i];
const item = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, source_path, filename, source_quality)
VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`,
[job!.id, i, s.source_path, s.filename || null, s.source_quality || 'original']);
await enqueue({ itemId: item!.id, jobId: job!.id });
}
} else {
const srcPaths = sources.map((s) => s.source_path).filter(Boolean);
const base = mode === 'generate' ? 'neu' : 'kombiniert';
const item = await one<{ id: string }>( const item = await one<{ id: string }>(
`INSERT INTO items (job_id, position, status, source_path, filename, source_quality) `INSERT INTO items (job_id, position, status, source_paths, filename, source_quality)
VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`, VALUES ($1,0,'queued',$2,$3,'original') RETURNING id`,
[job!.id, i, s.source_path, s.filename || null, s.source_quality || 'original']); [job!.id, srcPaths.length ? JSON.stringify(srcPaths) : null, base]);
await enqueue({ itemId: item!.id, jobId: job!.id }); await enqueue({ itemId: item!.id, jobId: job!.id });
} }
+15
View File
@@ -0,0 +1,15 @@
import type { APIRoute } from 'astro';
import { one } from '../../lib/db';
export const prerender = false;
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const s = await one<{ private_allowed: boolean }>('SELECT private_allowed FROM settings WHERE id=1');
const u = await one<{ private_forced: boolean }>('SELECT private_forced FROM users WHERE id=$1', [locals.user.uid]);
return new Response(JSON.stringify({
role: locals.user.role,
private_allowed: !!s?.private_allowed,
private_forced: !!u?.private_forced,
}), { headers: { 'Content-Type': 'application/json' } });
};
+5 -3
View File
@@ -1,13 +1,15 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { query } from '../../lib/db'; import { one, query } from '../../lib/db';
export const prerender = false; export const prerender = false;
// Aktive Modelle mit freundlichen Namen (für Studio-Auswahl + Ergebnis-Anzeige). // Aktive Modelle mit freundlichen Namen (für Studio-Auswahl + Ergebnis-Anzeige).
// NSFW-fähige Modelle nur, wenn allow_nsfw in den Einstellungen aktiv ist.
export const GET: APIRoute = async ({ locals }) => { export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); if (!locals.user) return new Response('Unauthorized', { status: 401 });
const s = await one<{ allow_nsfw: boolean }>('SELECT allow_nsfw FROM settings WHERE id=1');
const models = await query( const models = await query(
`SELECT model_id, label, description, is_default, supports_alpha FROM models `SELECT model_id, label, description, is_default, supports_alpha, nsfw FROM models
WHERE active ORDER BY sort, label`); WHERE active AND (nsfw=false OR $1=true) ORDER BY sort, label`, [!!s?.allow_nsfw]);
return new Response(JSON.stringify({ models }), { headers: { 'Content-Type': 'application/json' } }); return new Response(JSON.stringify({ models }), { headers: { 'Content-Type': 'application/json' } });
}; };
+50
View File
@@ -0,0 +1,50 @@
import type { APIRoute } from 'astro';
import { query, one } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** Bogen-Vorlagen: Papier, Marken, Bildformate und Stückzahlen — ohne Bilder. */
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const s = await one<any>('SELECT anonymous_generations FROM settings WHERE id=1');
const hideName = !!s?.anonymous_generations && locals.user.role !== 'admin';
const rows = await query(
`SELECT p.id, p.name, p.config, p.created_at, p.created_by,
${hideName ? 'NULL' : 'u.display_name'} AS by_name
FROM print_presets p LEFT JOIN users u ON u.id = p.created_by
ORDER BY p.name ASC LIMIT 200`);
return json({ presets: rows });
};
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const body = await request.json().catch(() => null) as any;
const name = String(body?.name || '').trim();
if (!name) return json({ error: 'Name fehlt.' }, 400);
if (!body?.config || typeof body.config !== 'object') return json({ error: 'Konfiguration fehlt.' }, 400);
// Vorlagen werden allen Nutzern ausgeliefert — deshalb eine harte Größengrenze.
const cfg = JSON.stringify(body.config);
if (cfg.length > 20000) return json({ error: 'Vorlage zu groß.' }, 400);
const mine = await one<{ n: string }>(
`SELECT count(*)::text AS n FROM print_presets WHERE created_by IS NOT DISTINCT FROM $1`, [locals.user.uid]);
if (Number(mine?.n || 0) >= 100) return json({ error: 'Zu viele Vorlagen — bitte zuerst aufräumen.' }, 400);
const row = await one(
`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3) RETURNING id, name, config`,
[name.slice(0, 80), locals.user.uid, cfg]);
return json({ preset: row });
};
export const DELETE: APIRoute = async ({ url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const id = url.searchParams.get('id');
if (!id) return json({ error: 'id fehlt.' }, 400);
// Nur eigene Vorlagen (Admins dürfen alles) — sonst löscht jeder jedem den Kita-Satz.
const isAdmin = locals.user.role === 'admin';
const del = await query(
`DELETE FROM print_presets WHERE id=$1 AND ($2::bool OR created_by IS NOT DISTINCT FROM $3) RETURNING id`,
[id, isAdmin, locals.user.uid]);
if (!del.length) return json({ error: 'Vorlage nicht gefunden oder nicht deine.' }, 404);
return json({ ok: true });
};
+291
View File
@@ -0,0 +1,291 @@
import type { APIRoute } from 'astro';
import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode, type Page, type Line } from '../../../lib/printlayout';
import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset, type Oversize } from '../../../lib/printoversize';
import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf, type PageExtras, type FooterPos } from '../../../lib/printrender';
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
import { cfgForKey } from '../../../lib/delivery';
import { uploadBuffer } from '../../../lib/picdrop';
import { one } from '../../../lib/db';
export const prerender = false;
const MAX_CELLS = 40; // verschiedene Bilder je Bogen
const MAX_COPIES = 200; // Exemplare je Bild
const MAX_PAGES = 60; // Posterdruck braucht mehr Seiten als ein Passbildbogen
const MAX_PIECES = 500; // Summe aller Exemplare — schützt den Packer vor Entartung
interface CellIn {
id: string;
src: PrintSource;
crop?: CropRel | null;
wMm?: number; hMm?: number;
size?: string; // Freitext („12x15", „35x45mm") als Alternative zu wMm/hMm
count?: number;
allowRotate?: boolean;
landscape?: boolean; // Bildformat quer statt hoch
fit?: 'cover' | 'contain';// abweichendes Seitenverhältnis: zuschneiden oder einpassen
bg?: string; // Randfarbe beim Einpassen
oversize?: Oversize; // größer als der Bogen: melden, überstehen lassen oder kacheln
overlapMm?: number; // Klebefalz beim Kacheln
}
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
let body: any;
try { body = await request.json(); } catch { return json({ error: 'Ungültige Anfrage.' }, 400); }
try {
const sheet = readSheet(body);
const marksMode: MarkMode = ['none', 'corner', 'grid'].includes(body?.marks?.mode) ? body.marks.mode : 'corner';
const dpi = clamp(Number(body?.dpi) || 300, 72, 1200);
const wantExt: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
const cellsIn: CellIn[] = Array.isArray(body?.cells) ? body.cells.slice(0, MAX_CELLS) : [];
if (!cellsIn.length) return json({ error: 'Keine Bilder angegeben.' }, 400);
const specs: PlaceSpec[] = [];
const resolved: { cell: CellIn; wMm: number; hMm: number }[] = [];
// Zellen, die nicht auf den Bogen passen und dafür einen eigenen Weg haben.
const big: { cell: CellIn; wMm: number; hMm: number; count: number; mode: Oversize }[] = [];
for (const [i, c] of cellsIn.entries()) {
const size = cellSize(c);
if (!size) return json({ error: `Bild ${i + 1}: Maß fehlt oder ist ungültig.` }, 400);
const id = String(c.id || `c${i}`);
const count = clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES);
const allowRotate = c.allowRotate !== false;
const mode: Oversize = c.oversize === 'overflow' || c.oversize === 'tile' ? c.oversize : 'fit';
if (mode !== 'fit' && !fitsOnSheet(size.w, size.h, sheet, allowRotate)) {
big.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h, count, mode });
continue;
}
specs.push({ id, wMm: size.w, hMm: size.h, count, allowRotate });
resolved.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h });
}
if (!specs.length && !big.length) return json({ error: 'Keine Bilder angegeben.' }, 400);
const totalPieces = specs.reduce((n, s) => n + s.count, 0) + big.reduce((n, b) => n + b.count, 0);
if (totalPieces > MAX_PIECES) return json({ error: `Zu viele Einzelbilder (${totalPieces}). Höchstens ${MAX_PIECES} je Bogen-Auftrag.` }, 422);
const plan = specs.length ? layout(specs, sheet) : { pages: [] as Page[], unplaced: [], perSheet: 0 };
if (!plan.pages.length && !big.length) {
return json({ error: 'Nichts platzierbar — Bildmaß größer als die Nutzfläche des Bogens.', unplaced: plan.unplaced }, 422);
}
// Für jedes Bild genau einmal rendern — auch bei 24 Exemplaren.
// Gedrehte Platzierungen brauchen eine eigene Fassung.
// Bild-Schlüssel bewusst NICHT aus der Zell-ID ableiten — eine Zelle namens
// „x::rot" würde sonst die gedrehte Fassung von „x" überschreiben.
const keyOf = (id: string, rot: boolean) => `${specs.findIndex((s) => s.id === id)}${rot ? 'r' : 'p'}`;
const images: Record<string, SheetCellImage> = {};
const needRotated = new Set<string>();
const needPlain = new Set<string>();
for (const pg of plan.pages) for (const p of pg.placements) (p.rotated ? needRotated : needPlain).add(p.specId);
for (const r of resolved) {
const buf = await loadSource(r.cell.src, locals.user as any);
if (needPlain.has(r.cell.id)) {
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: false, fit: fitOf(r.cell), background: bgOf(r.cell) });
images[keyOf(r.cell.id, false)] = { bytes: out.buffer, ext: out.ext };
}
if (needRotated.has(r.cell.id)) {
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: true, fit: fitOf(r.cell), background: bgOf(r.cell) });
images[keyOf(r.cell.id, true)] = { bytes: out.buffer, ext: out.ext };
}
}
// Platzierungen auf die passende Bildfassung umbiegen.
const pages = plan.pages.map((pg) => ({
placements: pg.placements.map((p) => ({ ...p, specId: keyOf(p.specId, p.rotated) })),
}));
const marks = plan.pages.map((pg) =>
cutMarks(pg, sheet, {
mode: marksMode,
lengthMm: numOr(body?.marks?.lengthMm, 4, 1, 20),
offsetMm: body?.marks?.offsetMm != null ? numOr(body.marks.offsetMm, 3, 0, 20) : undefined,
bleedMm: sheet.bleedMm,
}));
const extras: PageExtras[] = plan.pages.map(() => ({}));
// --- Übergroße Bilder: überstehen lassen oder auf mehrere Blätter kacheln ---
const notes: string[] = [];
for (const [bi, b] of big.entries()) {
const buf = await loadSource(b.cell.src, locals.user as any);
const opts = { ext: wantExt, fit: fitOf(b.cell), background: bgOf(b.cell) };
if (b.mode === 'overflow') {
const info = overflowPlacement(b.cell.id, 1, b.wMm, b.hMm, sheet, b.cell.allowRotate !== false);
const key = `o${bi}`;
const out = await renderCell(buf, b.cell.crop ?? null, b.wMm, b.hMm, dpi, { ...opts, rotate: info.rotated });
images[key] = { bytes: out.buffer, ext: out.ext };
for (let c = 0; c < b.count; c++) {
pages.push({ placements: [{ ...info.placement, specId: key, copy: c + 1 }] });
marks.push([]); // Marken wären hier sinnlos, die Kanten liegen außerhalb
extras.push({});
}
if (info.lostMm > 0.05)
notes.push(`${labelMm(b.wMm, b.hMm)} ragt über den Bogen: es fehlen ${fmtMm(info.lostLeft + info.lostRight)} in der Breite und ${fmtMm(info.lostTop + info.lostBottom)} in der Höhe.`);
} else {
const noteMm = noteInset(sheet);
const tp = tilePlan(b.wMm, b.hMm, tileSheet(sheet), numOr(b.cell.overlapMm, 10, 0, 40));
if (tp.tiles.length * b.count > MAX_PAGES)
return json({ error: `Der Posterdruck bräuchte ${tp.tiles.length * b.count} Blätter — bitte kleineres Maß oder größeres Papier.` }, 422);
const inset = sheet.marginMm || 0;
for (const [ti, t] of tp.tiles.entries()) {
const key = `t${bi}_${ti}`;
const crop = tileCrop(b.cell.crop ?? { x: 0, y: 0, w: 1, h: 1 }, t, b.wMm, b.hMm);
const out = await renderCell(buf, crop, t.wMm, t.hMm, dpi, { ...opts, rotate: false });
images[key] = { bytes: out.buffer, ext: out.ext };
// Klebefalz sichtbar machen: gestrichelt dort, wo das Nachbarblatt anschließt.
const dashed: Line[] = [];
const top = inset + noteMm;
if (t.glueRight) { const x = inset + t.wMm - tp.overlapMm; dashed.push({ x1: x, y1: top, x2: x, y2: top + t.hMm }); }
if (t.glueBottom) { const y = top + t.hMm - tp.overlapMm; dashed.push({ x1: inset, y1: y, x2: inset + t.wMm, y2: y }); }
for (let c = 0; c < b.count; c++) {
pages.push({ placements: [{ specId: key, copy: c + 1, x: inset, y: inset + noteMm, w: t.wMm, h: t.hMm, rotated: false }] });
marks.push([]);
extras.push({
note: `Blatt ${ti + 1}/${tp.tiles.length} · Reihe ${t.row + 1}, Spalte ${t.col + 1}` +
(tp.overlapMm > 0 ? ` · ${fmtMm(tp.overlapMm)} Klebefalz (gestrichelt)` : ''),
dashed,
});
}
}
notes.push(`${labelMm(b.wMm, b.hMm)} auf ${tp.cols} × ${tp.rows} Blätter verteilt, ${fmtMm(tp.overlapMm)} Überlappung zum Kleben.`);
}
}
if (pages.length > MAX_PAGES) return json({ error: `Zu viele Bogen (${pages.length}). Bitte Anzahl reduzieren.` }, 422);
const kinds = [
...resolved.map((r) => `${labelMm(r.wMm, r.hMm)}${specs.find((s) => s.id === r.cell.id)!.count > 1 ? ` ×${specs.find((s) => s.id === r.cell.id)!.count}` : ''}`),
...big.map((b) => `${labelMm(b.wMm, b.hMm)}${b.count > 1 ? ` ×${b.count}` : ''}${b.mode === 'tile' ? ' (Poster)' : ' (überstehend)'}`),
];
const footer = readFooter(body, sheet, kinds, dpi);
const bytes = await buildSheetPdf({
sheet, pages, marksPerPage: marks, images, extrasPerPage: extras,
title: String(body?.title || 'Klarbild Druckbogen'),
footer,
markWidthPt: numOr(body?.marks?.widthPt, 0.25, 0.1, 2),
});
const filename = sheetFilename(body?.name || 'druckbogen', 'pdf');
// Optional: den fertigen Bogen zusätzlich ins gewohnte Ziel schieben
// (Standard-Picdrop, NAS oder ein Zusatzziel) — wie bei den Bildern.
let delivered = '0', deliveryMsg = '';
if (body?.deliver) {
const key = body.deliver.target === '' || body.deliver.target == null ? 'picdrop' : String(body.deliver.target);
try {
// Zuerst den Galerienamen prüfen: keine Schrägstriche, kein „..", sonst
// ließe sich per posixpath.join aus dem Basisordner des Ziels ausbrechen.
const wish = String(body.deliver.gallery || '').trim();
if (wish && (wish.includes('..') || !/^[\p{L}\p{N} _.\-]{1,64}$/u.test(wish)))
throw new Error('Ungültiger Galeriename.');
const cfg = await cfgForKey(key);
if (!cfg) throw new Error('Ziel nicht konfiguriert.');
const s = await one<{ picdrop_default_gallery: string | null }>('SELECT picdrop_default_gallery FROM settings WHERE id=1');
const gallery = wish || s?.picdrop_default_gallery || 'POSTER LEA';
await uploadBuffer(cfg, gallery, filename, Buffer.from(bytes));
delivered = '1';
deliveryMsg = `Bogen nach „${gallery}" ausgeliefert.`;
} catch (e: any) {
deliveryMsg = `Auslieferung fehlgeschlagen: ${e?.message || e}`;
}
}
return new Response(Buffer.from(bytes), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'X-Klarbild-Pages': String(pages.length),
'X-Klarbild-Per-Sheet': String(plan.perSheet),
'X-Klarbild-Delivered': delivered,
...(notes.length ? { 'X-Klarbild-Notes': encodeURIComponent(notes.join(' ')) } : {}),
...(deliveryMsg ? { 'X-Klarbild-Delivery-Msg': encodeURIComponent(deliveryMsg) } : {}),
},
});
} catch (e: any) {
// Interne Details (Pfade, Hostnamen der Auslieferungsziele) bleiben im Log.
console.error('[print/sheet]', e?.message || e);
return json({ error: userMessage(e) }, 400);
}
};
/** Nur selbst formulierte Meldungen nach außen geben, nichts aus der Tiefe. */
const SAFE = /^(Papierformat|Bild \d|Maß|Ungültige|Unbekannte|Bild nicht|Kein Zugriff|Quelle|Ausschnitt|Zielbild|Zu viele)/;
function userMessage(e: any): string {
const m = String(e?.message || '');
return SAFE.test(m) ? m : 'Druckbogen konnte nicht erzeugt werden.';
}
function readSheet(body: any): SheetSpec {
let w: number | null = null, h: number | null = null;
const p = body?.paper;
if (typeof p === 'string') { const pp = paperById(p); if (pp) { w = pp.w; h = pp.h; } }
else if (p && typeof p === 'object') {
if (p.id) { const pp = paperById(String(p.id)); if (pp) { w = pp.w; h = pp.h; } }
if (p.wMm && p.hMm) { w = Number(p.wMm); h = Number(p.hMm); }
if (!w && p.size) { const s = parseSizeMm(String(p.size)); if (s) { w = s.w; h = s.h; } }
}
if (!w || !h) throw new Error('Papierformat fehlt oder ist unbekannt.');
if (body?.landscape) [w, h] = [h, w];
if (w < 20 || h < 20 || w > 2000 || h > 2000) throw new Error('Papierformat außerhalb des zulässigen Bereichs.');
const bleedMm = numOr(body?.bleedMm, 0, 0, 10);
const sheet: SheetSpec = {
wMm: w, hMm: h,
marginMm: numOr(body?.marginMm, 5, 0, 50),
gapMm: numOr(body?.gapMm, 4, 0, 100),
bleedMm,
center: body?.center !== false,
};
sheet.gapMm = effectiveGap(sheet);
return sheet;
}
function cellSize(c: CellIn): { w: number; h: number } | null {
let s: { w: number; h: number } | null = null;
if (Number(c.wMm) > 0 && Number(c.hMm) > 0) s = { w: Number(c.wMm), h: Number(c.hMm) };
else if (c.size) s = parseSizeMm(String(c.size));
if (!s || s.w < 5 || s.h < 5 || s.w > 2000 || s.h > 2000) return null;
return c.landscape ? { w: s.h, h: s.w } : s;
}
/** Millimeter menschenlesbar: „3 mm", „1,5 mm", „0 mm". */
const fmtMm = (n: number) => `${String(Math.round(n * 10) / 10).replace('.', ',')} mm`;
const FOOTER_POS: FooterPos[] = ['unten-links', 'unten-mitte', 'unten-rechts', 'oben-links', 'oben-mitte', 'oben-rechts'];
/** Fußzeile: aus, automatisch oder eigener Text an frei wählbarer Stelle. */
function readFooter(body: any, sheet: SheetSpec, kinds: string[], dpi: number) {
const f = body?.footer;
if (f === false || f === null) return null;
const auto = `Klarbild · ${labelMm(sheet.wMm, sheet.hMm)} · ${kinds.join(' · ')} · ${dpi} dpi · Druck bei 100 % (nicht „an Seite anpassen")`;
if (f === true || f == null) return { text: auto, position: 'unten-links' as FooterPos, sizePt: 6 };
const text = typeof f.text === 'string' && f.text.trim() ? f.text.trim().slice(0, 200) : auto;
const position = FOOTER_POS.includes(f.position) ? f.position as FooterPos : 'unten-links';
return { text, position, sizePt: numOr(f.sizePt, 6, 4, 14) };
}
const fitOf = (c: CellIn): 'cover' | 'contain' => (c.fit === 'contain' ? 'contain' : 'cover');
/** Randfarbe nur als sicheres Hex zulassen. */
const bgOf = (c: CellIn): string => (/^#[0-9a-f]{6}$/i.test(String(c.bg || '')) ? String(c.bg) : '#ffffff');
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
const numOr = (v: any, def: number, lo: number, hi: number) => {
const n = Number(v);
return Number.isFinite(n) ? clamp(n, lo, hi) : def;
};
+67
View File
@@ -0,0 +1,67 @@
import type { APIRoute } from 'astro';
import { renderCell, type CropRel } from '../../../lib/printrender';
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
import { parseSizeMm, mmToPx, labelMm } from '../../../lib/paper';
import { dpiCheck } from '../../../lib/printlayout';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/**
* Ein einzelnes Bild exakt auf ein Maß bringen ohne KI, ohne Bogen.
* Antwort: die fertige Bilddatei (PNG/JPG) mit dpi-Metadaten.
*/
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
let body: any;
try { body = await request.json(); } catch { return json({ error: 'Ungültige Anfrage.' }, 400); }
try {
const src: PrintSource = body?.src;
const crop: CropRel | null = body?.crop ?? null;
let size = (Number(body?.wMm) > 0 && Number(body?.hMm) > 0)
? { w: Number(body.wMm), h: Number(body.hMm) }
: parseSizeMm(String(body?.size || ''));
if (!size) return json({ error: 'Maß fehlt oder ist ungültig.' }, 400);
if (body?.landscape) size = { w: size.h, h: size.w };
if (size.w < 5 || size.h < 5 || size.w > 2000 || size.h > 2000) return json({ error: 'Maß außerhalb des zulässigen Bereichs.' }, 400);
const dpi = Math.min(1200, Math.max(72, Number(body?.dpi) || 300));
const ext: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
const buf = await loadSource(src, locals.user as any);
const fit = body?.fit === 'contain' ? 'contain' : 'cover';
const background = /^#[0-9a-f]{6}$/i.test(String(body?.bg || '')) ? String(body.bg) : '#ffffff';
const out = await renderCell(buf, crop, size.w, size.h, dpi,
{ ext, bleedMm: Math.min(10, Math.max(0, Number(body?.bleedMm) || 0)), fit, background });
// Hinweis, falls die Quelle für echte 300 dpi zu klein ist.
const cropW = (crop?.w ?? 1) * out.srcPx[0];
const check = dpiCheck(cropW, size.w, dpi);
return new Response(out.buffer, {
headers: {
'Content-Type': out.ext === 'png' ? 'image/png' : 'image/jpeg',
'Content-Disposition': `attachment; filename="${sheetFilename(body?.name || labelMm(size.w, size.h).replace(/[^0-9x×]/g, ''), out.ext)}"`,
'X-Klarbild-Px': `${out.width}x${out.height}`,
'X-Klarbild-Real-Dpi': String(check.dpi),
'X-Klarbild-Dpi-Ok': check.ok ? '1' : '0',
},
});
} catch (e: any) {
console.error('[print/single]', e?.message || e);
const m = String(e?.message || '');
const safe = /^(Maß|Ungültige|Unbekannte|Bild nicht|Kein Zugriff|Quelle|Ausschnitt|Zielbild)/.test(m);
return json({ error: safe ? m : 'Bild konnte nicht erzeugt werden.' }, 400);
}
};
/** Kleine Hilfe für Skripte/MCP: Maß → Pixel bei dpi. */
export const GET: APIRoute = async ({ url, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const size = parseSizeMm(url.searchParams.get('size') || '');
const dpi = Math.min(1200, Math.max(72, Number(url.searchParams.get('dpi')) || 300));
if (!size) return json({ error: 'Parameter size fehlt (z. B. 12x15 oder 35x45mm).' }, 400);
return json({ mm: size, dpi, px: { w: mmToPx(size.w, dpi), h: mmToPx(size.h, dpi) }, label: labelMm(size.w, size.h) });
};
+5 -3
View File
@@ -16,10 +16,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
const b = await request.json(); const b = await request.json();
const row = await one( const row = await one(
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, contour_mm, `INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, contour_mm,
model_key, delivery, picdrop_gallery, custom_instruction, is_default, created_by) model_key, delivery, picdrop_gallery, delivery_target_id, custom_instruction, mode, output_ext, is_default, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,false,$12) RETURNING *`, VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,false,$15) RETURNING *`,
[b.name, JSON.stringify(b.tasks || []), b.output_format, b.orientation, b.crop_mode || 'crop', [b.name, JSON.stringify(b.tasks || []), b.output_format, b.orientation, b.crop_mode || 'crop',
b.dpi || 300, b.contour_mm ?? null, b.model_key ?? null, b.delivery || 'library', b.dpi || 300, b.contour_mm ?? null, b.model_key ?? null, b.delivery || 'library',
b.picdrop_gallery ?? null, b.custom_instruction ?? null, locals.user.uid]); b.picdrop_gallery ?? null, b.delivery_target_id ?? null, b.custom_instruction ?? null,
['each', 'compose', 'generate'].includes(b.mode) ? b.mode : 'each',
(b.output_ext === 'jpg' || b.output_ext === 'png') ? b.output_ext : null, locals.user.uid]);
return json({ recipe: row }); return json({ recipe: row });
}; };
+29
View File
@@ -0,0 +1,29 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
const COLS = ['name', 'tasks', 'output_format', 'orientation', 'crop_mode', 'dpi', 'contour_mm',
'model_key', 'delivery', 'picdrop_gallery', 'delivery_target_id', 'custom_instruction', 'mode', 'output_ext'];
export const PATCH: APIRoute = async ({ params, request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json();
const sets: string[] = []; const args: any[] = [];
const set = (c: string, v: any) => { args.push(v); sets.push(`${c}=$${args.length}`); };
for (const c of COLS) {
if (c in b) set(c, c === 'tasks' ? JSON.stringify(b[c] || []) : (b[c] === '' ? null : b[c]));
}
if (!sets.length) return json({ error: 'Nichts zu ändern.' }, 400);
args.push(params.id);
const row = await one(`UPDATE recipes SET ${sets.join(',')} WHERE id=$${args.length} RETURNING *`, args);
return json({ recipe: row });
};
export const DELETE: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
await query('DELETE FROM recipes WHERE id=$1', [params.id]);
return json({ ok: true });
};
+5 -1
View File
@@ -31,11 +31,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
ext = meta.format === 'jpeg' ? 'jpg' : (meta.format || 'png'); ext = meta.format === 'jpeg' ? 'jpg' : (meta.format || 'png');
} }
const meta = await sharp(buf, { failOn: 'none' }).metadata(); const meta = await sharp(buf, { failOn: 'none' }).metadata();
// EXIF-Drehung einrechnen: der Browser zeigt das Bild orientiert an, also
// müssen auch die gemeldeten Maße orientiert sein (Zuschnitt im Druckmodul).
const turned = (meta.orientation ?? 1) >= 5;
const key = sourceKey(randomUUID(), ext); const key = sourceKey(randomUUID(), ext);
await putObject(key, buf, `image/${ext === 'jpg' ? 'jpeg' : ext}`); await putObject(key, buf, `image/${ext === 'jpg' ? 'jpeg' : ext}`);
out.push({ out.push({
source_path: key, filename: file.name, source_path: key, filename: file.name,
width: meta.width, height: meta.height, width: turned ? meta.height : meta.width,
height: turned ? meta.width : meta.height,
source_quality: 'original', source_quality: 'original',
}); });
} catch (e: any) { } catch (e: any) {
+114
View File
@@ -0,0 +1,114 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="Changelog · Klarbild">
<div class="clog">
<div class="ckopf">
<h1>Changelog</h1>
<a class="zurueck" href="/anleitung">← Zur Hilfe</a>
</div>
<p class="lead">Was in Klarbild neu ist. Neueste Änderungen oben.</p>
<section>
<div class="ver"><span class="tag">20.08.2026</span><h2>Übergroße Bilder, Bild duplizieren, freie Beschriftung &amp; neuer Aufbau</h2></div>
<ul>
<li><b>20 × 30 auf DIN-A4-Papier — jetzt geht das.</b> Wenn das Wunschmaß größer ist als der Bogen, sagt das Werkzeug nicht mehr nur „passt nicht", sondern bietet zwei Wege an: <b>Überstehen</b> legt das Bild mittig aufs ganze Blatt — was hinausragt, fehlt hinterher, und wie viel das ist, steht vorher dabei (bei 20 × 30 cm auf A4 sind es genau 3 mm in der Höhe). <b>Posterdruck</b> verteilt das Bild auf mehrere Blätter; die Kacheln überlappen sich um einen Klebefalz, der gestrichelt markiert wird, und jedes Blatt trägt seine Nummer mit Reihe und Spalte. 30 × 40 cm auf A4 ergibt so 2 × 2 Blätter.</li>
<li><b>Bild duplizieren.</b> Dasselbe Bild lässt sich mit einem Knopf kopieren und dann in einem zweiten Maß auf denselben Bogen setzen — etwa ein Porträt einmal in 10 × 15 und einmal als Passbild auf ein Blatt Fotopapier. Kein Umbenennen und erneutes Hochladen mehr.</li>
<li><b>Beschriftung selbst bestimmen.</b> Die kleine Zeile unter dem Bogen lässt sich ausschalten, mit eigenem Text versehen und an eine von sechs Stellen legen (oben oder unten, links, mittig oder rechts). Ist an der gewählten Kante kein Platz frei, entfällt sie automatisch — über ein Bild wird nie gedruckt.</li>
<li><b>Neuer Aufbau am Rechner:</b> links arbeiten, rechts die Vorschau, die beim Scrollen mitläuft. Die Einstellungsgruppen liegen zugeklappt nebeneinander und klappen über die volle Breite auf. Keine endlose Spalte neben einer leeren mehr.</li>
<li><b>Am Handy wie eine App:</b> Die Vorschau steht oben, „Druck-PDF erzeugen" liegt als fester Balken über der Navigation, jede Bildzeile zeigt Vorschau und Regler nebeneinander, und alle Knöpfe sind daumengroß. Tablet-Breiten sind eigens geprüft.</li>
<li><b>Öffentliche Vorstellungsseite</b> unter <code>/vorstellung</code> — beschreibt das Druckmodul ohne Login und ohne Bedienung, gedacht zum Herzeigen und für eine mögliche Veröffentlichung als Open Source.</li>
</ul>
</section>
<section>
<div class="ver"><span class="tag">19.08.2026</span><h2>Aus „Passbilder" wird „Drucken" — 86 Bildmaße, Zuschnitt repariert</h2></div>
<ul>
<li><b>Der Bereich heißt jetzt „Drucken"</b> (vorher „Passbilder") — er kann ja weit mehr als Passbilder. Alte Verweise leiten automatisch weiter.</li>
<li><b>86 Bildmaße in 10 Gruppen:</b> Passbild, Klein, Foto, Zoll (US), Sofortbild, Poster &amp; Rahmen, Quadrat, DIN, Karten und reine Seitenverhältnisse — dazu 19 Papierformate und jederzeit ein freies Maß.</li>
<li><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.</li>
<li><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.</li>
</ul>
</section>
<section>
<div class="ver"><span class="tag">18.08.2026</span><h2>Neu: Drucken — Bilder auf exakte Maße bringen, ganz ohne KI</h2></div>
<ul>
<li><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".</li>
<li><b>Mehrere Bilder auf einen Bogen.</b> Gleich große Bilder kommen in ein exaktes Raster, gemischte Formate ordnet ein Packer so an, dass möglichst wenige Bogen nötig sind. Ein 13 × 18, ein 9 × 13 und sechs Passbilder passen zusammen auf ein DIN-A4-Blatt.</li>
<li><b>Schnittmarken wie in der Druckvorstufe:</b> feine Eckmarken außerhalb jedes Bildes oder durchgehende Linien über den ganzen Bogen — unterbrochen, wo ein anderes Motiv liegt. Länge, Abstand und Beschnittzugabe sind einstellbar.</li>
<li><b>Zuschneiden oder Rand lassen.</b> Passt das Seitenverhältnis nicht, entscheidest du: zuschneiden bis alles ausgefüllt ist, oder das ganze Bild einpassen und außen eine Randfarbe stehen lassen. Verzerrt wird nie. Der Ausschnitt lässt sich im Zuschnitt-Fenster verschieben und zoomen; die Drehung aus Handyfotos (EXIF) wird vorab eingerechnet.</li>
<li><b>Papier frei wählbar:</b> DIN A7 bis A0, Fotopapier 9 × 13 bis 20 × 30, US-Maße — oder ein eigenes Papiermaß. Randlos, 5 mm oder 10 mm Rand.</li>
<li><b>Vorlagen</b> für wiederkehrende Bogen (Kita-Satz, Schulsatz, Passbildbogen, 2 × 10 × 7,5 auf Fotopapier) — eigene lassen sich sichern.</li>
<li><b>Auch über Telegram und den MCP-Server</b> erreichbar, und der fertige Bogen kann direkt an Picdrop oder ein Sicherungsziel ausgeliefert werden.</li>
<li><b>Ohne KI, ohne Kosten:</b> reine Geometrie im Haus. Kein Modell, kein Schlüssel, keine Bilder, die den Server verlassen.</li>
</ul>
</section>
<section>
<div class="ver"><span class="tag">24.07.2026</span><h2>JPG-Auslieferung, eigene Formate, fehlgeschlagene Aufträge & KI-Doku</h2></div>
<ul>
<li><b>Picdrop 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 Picdrop-Weboberfläche.)</li>
<li><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.</li>
<li><b>Auftrag löschen</b> direkt aus der Warteschlange (für alte/fehlgeschlagene Aufträge). Fehlende Quellen melden jetzt einen klaren Hinweis statt kryptischer Technikfehler.</li>
<li><b>KI-lesbare Doku</b> unter <code>/llms.txt</code> — beschreibt Zweck, Formate, API und MCP-Werkzeuge für Assistenten.</li>
<li><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.</li>
<li><b>Eigene Formate:</b> im Studio „Eigenes Maß (cm)" — einfach z. B. <code>25x35</code> eintippen; für Sticker reicht eine Zahl (<code>5</code> = 5×5 cm). Presets speichern das mit.</li>
<li><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").</li>
<li><b>Endzeit im Auftrag:</b> Warteschlange und Auftrag zeigen, wann er fertig wurde.</li>
<li><b>Saubere Dateinamen:</b> keine doppelten Datums-/UUID-Ketten mehr im Namen.</li>
<li><b>Metadaten-Beileger pro Ziel:</b> für Picdrop, NAS und jedes Zusatzziel getrennt schaltbar, ob die <b>.md</b>-Datei mitgeschickt wird.</li>
<li><b>Schnelleres Laden:</b> Vollbild/Vorschau kommt als kleines JPG (~5× kleiner), Kacheln laden zuerst ein Thumbnail; fehlende Thumbnails per Knopf nachgenerierbar.</li>
<li><b>Picdrop-Werkzeuge im Admin:</b> „Ordner anzeigen" (was liegt per SFTP wo) und „Reste aufräumen" (liegengebliebene .tmp-Dateien entfernen).</li>
</ul>
</section>
<section>
<div class="ver"><span class="tag">23.07.2026</span><h2>Kombinieren, Erzeugen & zweite Sicherung</h2></div>
<ul>
<li><b>Kombinieren:</b> mehrere Bilder + Beschreibung zu einem neuen Bild (z. B. „…​mit unserer Hündin Frieda").</li>
<li><b>Neu erzeugen:</b> Bilder allein aus Text, ganz ohne Vorlage.</li>
<li><b>The Frame „Erweitern":</b> Szene rund ums Motiv weiter generieren (Outpainting) statt nur zuschneiden.</li>
<li><b>Weiterbearbeiten:</b> ein fertiges Bild als neue Vorlage übernehmen und erneut bearbeiten.</li>
<li><b>Alternativen:</b> weitere Fassungen desselben Bildes erzeugen — gruppiert mit Umschalter.</li>
<li><b>Ordner:</b> in der Bibliothek filtern, umbenennen, löschen; je Ordner eigene Picdrop-Galerie.</li>
<li><b>Sammel-Aktionen:</b> mehrere Bilder als ZIP laden und auf einmal in einen Ordner verschieben.</li>
<li><b>Vorschaubilder</b> für die Bibliothek (schnell & sparsam).</li>
<li><b>Modelle:</b> Qualitätsstufen inkl. Open Source (FLUX.2); genutztes Modell wird angezeigt.</li>
<li><b>Farbmarkierungen</b> wie bei Picdrop (Rot / Orange / Grün / Final): pro Bild setzen und danach filtern — die Markierung steht auch in der begleitenden .md-Datei.</li>
<li><b>„Laden" öffnet groß:</b> Tipp auf „Laden" zeigt das Bild formatfüllend — auf iPhone/iPad dann lange tippen und <b>„In Fotos sichern"</b> (zusätzlich klassischer Datei-Download).</li>
<li><b>Rechte & Datenschutz:</b> Sichtbarkeit einstellbar (jede/r nur eigene Bilder oder „alle sehen alles"), Generierungen optional anonym, Admin sieht alles.</li>
<li><b>Private Session:</b> nur erzeugen, danach vergessen — kein Bibliothekseintrag, keine Auslieferung/Sicherung, kein gespeicherter Prompt. Vom Admin global erlaubbar oder pro Nutzer erzwingbar.</li>
<li><b>NSFW-Schalter:</b> als NSFW markierte Modelle sind nur bei aktiver Admin-Einstellung wähl-/sichtbar.</li>
<li><b>Umwandeln (ein Bild + Text):</b> ein einzelnes Bild per Beschreibung umwandeln — z. B. „Foto → Ölgemälde" — und direkt aufs Format bringen. Kein Zwang mehr, zwei Bilder hochzuladen.</li>
<li><b>Mehrere Ausgabeziele:</b> zusätzlich zum Standard-Picdrop beliebige weitere FTP/SFTP-Ziele; im Studio unter „Wohin?" auch <b>NAS</b> und einzelne FTP-Ziele wählbar; ein Preset/Ordner kann fest auf ein Ziel zeigen.</li>
<li><b>Backup auf alle Ziele:</b> nicht nur NAS — jedes Ziel als „Backup" markierbar; „Alle Bilder sichern" spiegelt auf alle Backup-Ziele.</li>
<li><b>Metadaten-Beileger:</b> optional zu jedem Bild eine <b>.md-Datei</b> (Prompt, Modell, Format …) mit auf Picdrop/NAS/FTP.</li>
<li><b>Prompt-Anzeige:</b> jeder generierte Bild zeigt in der Bibliothek seinen Prompt (mit Kopieren).</li>
<li><b>Presets selbst verwalten:</b> aktuelle Einstellungen als Preset speichern, laden, löschen.</li>
<li><b>API-Token & MCP-Server:</b> externer Zugriff — Bilder aus einem Ordner oder von iMessage per Assistent an Klarbild übergeben.</li>
<li><b>The Frame</b> liefert jetzt automatisch in die Galerie <b>„TheFrame-Backgrounds"</b>.</li>
<li><b>Zweite Sicherung (NAS):</b> jedes Ergebnis zusätzlich per SFTP/FTPS aufs NAS (auch über Tailscale) — mit „NAS ✓"-Anzeige und „Alle Bilder aufs NAS sichern".</li>
<li><b>Speicherverwaltung:</b> Vorschaubilder, Quellen löschen, automatische Aufbewahrungsfrist.</li>
<li><b>Telegram komplett knopfbasiert:</b> Dauertastatur & Menü — Bearbeiten, Kombinieren, „✨ Neues Bild", Weiterbearbeiten, ganz ohne Befehle.</li>
<li><b>Zurücksetzen:</b> Bibliothek & Aufträge sauber leeren (mit Sicherheitsabfrage).</li>
<li><b>Hilfe-Seite</b> und dieser Changelog.</li>
<li>Unter der Haube: echter Kostendeckel, verlässlicher Auftragsabschluss, Erreichbarkeits-Diagnose fürs NAS.</li>
</ul>
</section>
</div>
<style>
.clog{max-width:760px;margin:0 auto;}
.ckopf{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}
.clog h1{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:6px 0 4px;}
.zurueck{color:var(--accent);text-decoration:none;font-size:.9rem;}
.lead{color:var(--soft);margin:4px 0 20px;}
.clog section{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin-bottom:14px;}
.ver{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
.ver h2{font-size:1.05rem;margin:0;}
.tag{font-family:var(--font-mono);font-size:10px;letter-spacing:.08em;background:var(--accent-bg);color:var(--accent);padding:3px 9px;border-radius:20px;}
.clog ul{margin:0;padding-left:20px;line-height:1.65;}
.clog li{margin-bottom:6px;}
</style>
</Base>
+4
View File
@@ -0,0 +1,4 @@
---
// Alte Adresse — die Funktion heißt jetzt „Drucken".
return Astro.redirect('/drucken', 301);
---
+18
View File
@@ -0,0 +1,18 @@
---
import Base from '../layouts/Base.astro';
import PrintApp from '../components/PrintApp.tsx';
---
<Base title="Drucken · Klarbild">
<h1 class="seiten-titel">Drucken</h1>
<p class="seiten-lead">
Ohne KI: Bilder auf exakte Maße bringen und mehrere davon in 100-%-Größe mit
Schnittmarken auf einen Bogen setzen — vom biometrischen Passbild über den
Kita-Satz bis zum 30 × 40-Poster.
</p>
<PrintApp client:load />
<style>
.seiten-titel{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
.seiten-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}
@media(max-width:560px){.seiten-titel{font-size:1.35rem;}.seiten-lead{font-size:12.5px;}}
</style>
</Base>
+138
View File
@@ -0,0 +1,138 @@
import type { APIRoute } from 'astro';
export const prerender = false;
/**
* Maschinen-/KI-lesbare Kurzdoku unter /llms.txt (öffentlich, ohne Geheimnisse).
* Beschreibt Zweck, Fähigkeiten und die HTTP-/MCP-Schnittstelle von Klarbild,
* damit ein KI-Agent das Tool ohne Weboberfläche bedienen kann.
*/
const BODY = `# Klarbild
> Selbst gehostetes KI-Bildwerkzeug: verwandelt Screenshots und Fundstücke in
> saubere, druckfertige Bilder (bereinigen, freistellen, exakte Fotoformate,
> The-Frame-Querformat, Sticker mit Kontur), liefert an Picdrop (SFTP/FTPS)
> aus und sichert auf beliebige Backup-Ziele (z. B. NAS). Zusätzlich ein
> KI-freies Druckmodul (/drucken): Bilder exakt auf physische Maße bringen und mehrere
> davon in 100-%-Größe mit Schnittmarken auf einen Druckbogen setzen (PDF).
> Bedienung über Web, Telegram-Bot und HTTP-API/MCP.
Basis-URL: https://klarbild.heidrich-digital.de
Öffentliche Vorstellung des Druckmoduls (ohne Login, reine Beschreibung): /vorstellung
Sprache der Oberfläche: Deutsch. Stack: Astro 5 (SSR) · Postgres · sharp · pg-boss.
## Authentifizierung (für Agenten)
- Header: \`Authorization: Bearer <API-Token>\` (Admin → „Automatisierung / MCP-Zugriff").
- Der Token gilt für \`/api/*\` — NICHT für \`/api/admin/*\` (nur per Login-Session).
- Ohne Token/Session antworten API-Routen mit 401.
## Kernbegriffe
- Modus (mode): \`each\` (Screenshot bereinigen/freistellen/formatieren),
\`compose\` (ein Bild umwandeln ODER mehrere kombinieren, mit Textbeschreibung),
\`generate\` (neues Bild allein aus Text).
- Aufgaben (tasks, nur bei each): \`clean\`, \`cutout\`, \`format\`, \`contour\`.
- Rezept (recipe): gespeicherte Voreinstellung. Auftrag (job): ein Stapel; Position (item): ein Bild.
- Auftrags-Status: queued, running, paused, done, failed, cancelled. \`failed\` = alle Positionen fehlgeschlagen.
## Formate (output_format)
- Feste Schlüssel: 9x13,10x15,13x18,15x20,20x30,30x40,30x45,40x50,40x60,50x70,60x90,
A4,A3,A2,20x20,30x30, theframe (3840x2160), hochformat (2160x3840), keep (Original behalten).
- Freie Formate ohne Vorgabe: \`BREITExHOEHE\` in cm (z. B. \`25x35\`) oder \`sticker<N>\`
für N×N cm (z. B. \`sticker5\` = 5×5 cm). Grenze 300 cm. Exakte Pixel: round(cm/2.54*dpi).
- Dateiformat (output_ext): \`png\` (verlustfrei, Transparenz) oder \`jpg\` (klein, Picdrop-freundlich).
JPG nur ohne Transparenz; freigestellte Motive bleiben immer PNG. Global (Admin) oder pro Rezept.
## HTTP-API (Auswahl, JSON)
- GET /api/health Status (öffentlich).
- GET /api/me aktueller Nutzer/Rechte.
- GET /api/models verfügbare Bildmodelle (Qualitätsstufen).
- GET /api/recipes Rezepte/Presets auflisten.
- POST /api/recipes Rezept anlegen. Body u. a.: name, mode, tasks[], output_format,
orientation(portrait|landscape), crop_mode(crop|extend), dpi, contour_mm, output_ext(png|jpg|null),
delivery(library|picdrop|both), picdrop_gallery, delivery_target_id.
- DELETE /api/recipes/:id Rezept löschen.
- GET /api/delivery-targets Auslieferungs-/Backup-Ziele.
- POST /api/jobs Auftrag starten. Body: { recipe, mode, delivery, private,
prompt_text?(compose/generate), sources:[{source_path, filename}] }. Antwort: { jobId }.
- GET /api/jobs letzte Aufträge. GET /api/jobs/:id Auftrag + Positionen (inkl. error_message, finished_at).
- POST /api/jobs/:id/{pause|resume|cancel|retry-failed|delete}.
- POST /api/items/:id/{retry|reuse|alternative}.
- GET /api/items/:id/file Ergebnisbild. Query: ?thumb=1 (Vorschau), ?preview=1 (kleines JPG fürs Vollbild),
?download=1 (als Datei), ?src=1 (Quelle). Content-Type wird aus den Magic Bytes bestimmt (PNG/JPG/WEBP).
## Drucken exakte Maße & Druckbogen (ohne KI, /drucken)
Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kosten.
- Maßangaben: \`12x15\` = 12×15 cm · \`35x45mm\` · \`5\` = 5×5 cm · \`4:3/15\` = Verhältnis 4:3, längere Kante 15 cm.
- Papierformate (id): A6,A5,A4,A3,A3plus(329×483 mm),A2, F9x13,F10x15,F13x18,F15x20,F20x30, Letter, Legal oder freies Maß.
- Bildformate: 86 Vorgaben in 10 Gruppen (Passbild, Klein, Foto, Zoll (US), Sofortbild,
Poster & Rahmen, Quadrat, DIN, Karten, Seitenverhältnis) oder freies Maß. Auswahl der ids:
P35x45 (biometrisch), P50x50 · K20x30,K30x40,K40x50,K45x60,K60x80,K60x90 ·
S9x13,S10x15,S11x15,S12x15,S13x18,S15x20,S18x24,S20x25,S20x30,S24x30 ·
R30x40,R30x45,R40x50,R40x60,R50x70,R60x80,R60x90,R70x100 (Poster & Rahmen) ·
Q10x10,Q13x13,Q20x20,Q30x30 · DA6,DA5,DA4,DA3,DA2 ·
W16x9,W9x16,W3x2,W4x3,W5x4 (Seitenverhältnisse als Druckmaß, u. a. The Frame).
Damit sind alle Formate der KI-Generierung (src/lib/format.ts) auch hier druckbar.
- Passt das Bild nicht zum Format (fit je Zelle): \`cover\` = zuschneiden bis ausgefüllt (Standard,
nichts bleibt leer, außen fehlt etwas) oder \`contain\` = ganzes Bild einpassen, außen bleibt die
Randfarbe \`bg\` (#rrggbb, Standard #ffffff). Verzerrt wird nie.
- Schnitthilfen (marks.mode): \`none\` · \`corner\` (Eckmarken außerhalb des Endformats, Standard 4 mm lang,
3 mm Versatz, 0,25 pt Haarlinie) · \`grid\` (durchgehende Linien über den Bogen, laufen nie durch ein Motiv).
- Beschnittzugabe (bleedMm): das Bild ragt je Seite darüber hinaus; die Trimmbox bleibt exakt der gewählte Ausschnitt.
Der Abstand zwischen zwei Bildern ist immer 2 × Beschnittzugabe.
- Platzierung: gleich große Bilder exaktes Raster; gemischte Größen MaxRects-Packer mit fester
Ausrichtung je Format (probiert alle Ausrichtungs-Kombinationen und nimmt die mit den wenigsten Bogen).
- Die PDF-Seite hat exakt das Bogenmaß. Beim Drucken Tatsächliche Größe / 100 %" wählen — nicht „an Seite anpassen".
- **Größer als der Bogen** (z. B. 20 × 30 cm auf DIN-A4-Fotopapier) je Zelle über \`oversize\`:
\`fit\` = melden, dass es nicht passt (Standard) · \`overflow\` = mittig aufs ganze Blatt legen,
der Überstand wird beim Drucken abgeschnitten (20 × 30 auf A4: genau 3 mm in der Höhe) ·
\`tile\` = Posterdruck über mehrere Blätter mit Klebefalz (\`overlapMm\`, Standard 10 mm, gestrichelt markiert;
jedes Blatt trägt Nummer, Reihe und Spalte). 30 × 40 cm auf A4 ergibt 2 × 2 Blätter.
- **Beschriftung** (\`footer\`): \`false\` = aus · \`true\` = automatisch (Maße, dpi, Druckhinweis) ·
\`{ text, position, sizePt }\` = eigener Text. Position: \`unten-links|unten-mitte|unten-rechts|
oben-links|oben-mitte|oben-rechts\`. Die Zeile entfällt automatisch, wenn an der gewählten
Kante kein Platz frei ist sie wird nie über ein Bild gedruckt.
- Dasselbe Bild darf mehrfach im \`cells\`-Feld stehen (verschiedene \`id\`, gleiche \`src\`), um es
in mehreren Maßen auf einen Bogen zu setzen. In der Weboberfläche macht das der Knopf Duplizieren".
- POST /api/print/single ein Bild exakt auf Maß. Body: { src:{kind:'upload',path}|{kind:'item',id},
crop?:{x,y,w,h} (relativ 0..1; ohne Angabe mittiger Ausschnitt), size|wMm+hMm, landscape?, dpi?, ext?(jpg|png), bleedMm? }.
Antwort: die Bilddatei. Header: X-Klarbild-Px, X-Klarbild-Real-Dpi, X-Klarbild-Dpi-Ok.
- GET /api/print/single?size=12x15&dpi=300 nur rechnen: mm Pixel.
- POST /api/print/sheet Druckbogen als PDF. Body: { paper:{id}|{size}|{wMm,hMm}, landscape?, marginMm?,
gapMm?, bleedMm?, center?, dpi?, ext?, footer?, marks:{mode,lengthMm?,offsetMm?},
cells:[{ id, src, crop?, size|wMm+hMm, landscape?, count?, allowRotate?, fit?, bg?,
oversize?(fit|overflow|tile), overlapMm? }] }.
Antwort: application/pdf. Header: X-Klarbild-Pages, X-Klarbild-Per-Sheet.
Grenzen: 40 Zellen, 200 Exemplare je Zelle, 500 Einzelbilder und 60 Bogen je Auftrag.
- GET/POST/DELETE /api/print/presets Bogen-Vorlagen (Papier, Marken, Formate, Stückzahlen).
Mitgeseedet: Kita-Satz (A4)", „Schulsatz klein (A4)", Passbildbogen 35×45 (A4)",
2 × 10×7,5 auf Fotopapier 10×15". Eine Vorlage mit mehreren Formaten und nur einem Bild
klont das Bild in alle Formate.
- Bogen ausliefern: \`deliver: { target: ''|'nas'|<uuid>, gallery? }\` in /api/print/sheet legt das PDF
zusätzlich ins Ziel. Antwort bleibt das PDF; Header X-Klarbild-Delivered (0|1) und
X-Klarbild-Delivery-Msg (URI-kodiert).
## MCP-Server (für Assistenten)
Werkzeug \`klarbild\` (mcp/klarbild-mcp.mjs), nutzt denselben API-Token:
- list_recipes verfügbare Rezepte.
- process_images Bilder (lokaler Ordner / URLs) mit einem Rezept verarbeiten lassen.
- job_status Fortschritt/Ergebnis eines Auftrags abfragen.
- exact_size OHNE KI: ein Bild exakt auf ein Maß bringen, Datei lokal speichern.
- print_sheet OHNE KI: Druckbogen (mehrere Bilder × Stückzahl, Schnittmarken) als PDF lokal speichern.
## Auslieferung
- Picdrop = SFTP/FTPS-Ziel; Galerie = Unterordner unter dem Basispfad. Voller Pfad = Basisordner + Galerie
(wird erst beim Ausliefern zusammengesetzt). Standard-Galerie POSTER LEA"; „The Frame" TheFrame-Backgrounds".
- Ausgeliefert wird das Ergebnis im gewählten Dateiformat (output_ext, global oder pro Auftrag) PNG oder JPG. Freigestellte Motive bleiben PNG.
- Optionaler .md-Metadaten-Beileger je Ziel schaltbar (Picdrop/NAS/Zusatzziel).
## Datenschutz
- Private Session (job.private=true): kein Bibliothekseintrag, keine Auslieferung/Sicherung,
kein gespeicherter Prompt; Ergebnis wird nach kurzer Zeit gelöscht.
- Sichtbarkeit global steuerbar (nur eigene / alle sehen alles); Admin sieht alles.
Weitere Doku (menschlich): In-App /anleitung und /changelog.
`;
export const GET: APIRoute = async () =>
new Response(BODY, { headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' } });
+4
View File
@@ -0,0 +1,4 @@
---
// Alte Adresse — die Funktion heißt jetzt „Drucken".
return Astro.redirect('/drucken', 301);
---
+315
View File
@@ -0,0 +1,315 @@
---
// Öffentliche Vorstellungsseite für das Druckmodul.
// Bewusst ohne Login und ohne Bedienelemente: Sie zeigt, was das Werkzeug kann,
// man kann es hier aber nicht benutzen. Gedacht als Landeseite für ein
// Open-Source-Release (Gitea/GitHub) und zum Herzeigen.
import Base from '../layouts/Base.astro';
import { PHOTO_SIZES, PAPERS } from '../lib/paper';
import { layout, cutMarks, type SheetSpec } from '../lib/printlayout';
const anzahlFormate = PHOTO_SIZES.length;
const anzahlPapiere = PAPERS.length;
const anzahlGruppen = new Set(PHOTO_SIZES.map((p) => p.group)).size;
const koennen = [
{
kicker: 'Maßhaltig',
titel: 'Millimeter statt „ungefähr“',
text: 'Jedes Endmaß wird in Millimetern gerechnet und erst beim Rendern in Pixel umgesetzt. ' +
'Das PDF hat exakt das Bogenmaß, jedes Bild darin exakt sein Zielmaß. Nachgemessen wird nicht geschätzt, ' +
'sondern gemessen: Wir rastern die fertigen PDFs mit 254 dpi und zählen die Pixelkanten nach.',
beleg: '13 × 18 cm → gemessen 130,1 × 180,1 mm',
},
{
kicker: 'Platz',
titel: 'Mehrere Bilder auf einen Bogen',
text: 'Gleich große Bilder kommen in ein exaktes Raster. Bei gemischten Formaten sucht ein Packer ' +
'(MaxRects, „Best Short Side Fit“) die Anordnung mit den wenigsten Bogen — und probiert dafür jede ' +
'Kombination aus Hoch- und Querlage durch. Ein Passbild-Satz und ein 13 × 18 passen so auf dasselbe Blatt.',
beleg: 'Kita-Satz: 9 Teile statt 3 auf einem A4-Bogen',
},
{
kicker: 'Schneiden',
titel: 'Schnittmarken wie in der Druckvorstufe',
text: 'Feine Eckmarken außerhalb jedes Bildes oder durchgehende Rasterlinien über den ganzen Bogen. ' +
'Die Linien werden dort unterbrochen, wo ein anderes Motiv liegt — es wird also nie quer über ein ' +
'Bild gezeichnet. Länge und Abstand sind einstellbar, Beschnittzugabe ebenso.',
beleg: 'Eckmarken · durchgehendes Raster · Beschnittzugabe 010 mm',
},
{
kicker: 'Zu groß',
titel: '20 × 30 auf A4 — trotzdem',
text: 'Der Alltagsfall: Es soll ein 20 × 30 werden, im Haus liegt aber nur A4-Fotopapier. ' +
'Statt „geht nicht“ gibt es zwei Wege. Überstehen legt das Bild mittig aufs ganze Blatt; ' +
'was hinausragt, fehlt hinterher — und wie viel das ist, steht vorher da.',
beleg: '20 × 30 cm auf A4: es fehlen genau 3 mm in der Höhe',
},
{
kicker: 'Poster',
titel: 'Ein Bild, mehrere Blätter',
text: 'Der zweite Weg für große Maße: Das Bild wird auf mehrere Blätter verteilt. Die Kacheln ' +
'überlappen sich um einen Klebefalz, der auf beiden Nachbarblättern gedruckt und gestrichelt ' +
'markiert wird. Jedes Blatt trägt seine Nummer samt Reihe und Spalte.',
beleg: '30 × 40 cm auf A4 → 2 × 2 Blätter, 10 mm Klebefalz',
},
{
kicker: 'Zuschnitt',
titel: 'Der Ausschnitt bleibt deiner',
text: '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.',
beleg: 'EXIF-Drehung wird vorab eingerechnet — Handyfotos stehen richtig',
},
];
const daten = [
{ zahl: String(anzahlFormate), label: 'Bildformate', fein: `in ${anzahlGruppen} Gruppen — Passbild bis 100 × 140 cm` },
{ zahl: String(anzahlPapiere), label: 'Papierformate', fein: 'DIN A7A0, Fotopapier, US-Maße, freies Maß' },
{ zahl: '0', label: 'KI im Spiel', fein: 'reine Geometrie, kein Modell, kein Upload nach außen' },
{ zahl: '1:1', label: 'Ausgabe', fein: 'PDF in Originalgröße, 721200 dpi' },
];
// Der Beispielbogen unten wird nicht gezeichnet, sondern gerechnet — mit genau
// demselben Packer, den das Werkzeug benutzt. Ändert sich der Algorithmus,
// ändert sich das Bild auf dieser Seite mit.
const musterBogen: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 8, bleedMm: 0, center: true };
const musterPlan = layout([
{ id: 'gross', wMm: 130, hMm: 180, count: 1, allowRotate: true },
{ id: 'klein', wMm: 90, hMm: 130, count: 1, allowRotate: true },
{ id: 'pass', wMm: 35, hMm: 45, count: 6, allowRotate: true },
], musterBogen);
const musterSeite = musterPlan.pages[0];
const musterMarken = musterSeite ? cutMarks(musterSeite, musterBogen, { mode: 'corner', lengthMm: 3, offsetMm: 1 }) : [];
const musterFarbe: Record<string, string> = {
gross: 'oklch(45% 0.23 267 / 0.09)', klein: 'oklch(45% 0.23 267 / 0.20)', pass: 'oklch(45% 0.23 267 / 0.34)',
};
const musterAnzahl = musterSeite ? musterSeite.placements.length : 0;
/** Kompaktes Maß fürs Kästchen: unter 10 cm in mm, sonst in cm. */
const kurzMass = (w: number, h: number) => w < 100 && h < 100
? `${Math.round(w)}×${Math.round(h)}`
: `${Math.round(w / 10)}×${Math.round(h / 10)} cm`;
const schritte = [
{ n: '01', t: 'Bilder wählen', d: 'Hochladen, aus der Bibliothek holen oder ein vorhandenes Bild duplizieren, um es in zwei Maßen zu drucken.' },
{ n: '02', t: 'Maße setzen', d: 'Je Bild ein Zielmaß, Anzahl, Hoch oder Quer, zuschneiden oder Rand lassen.' },
{ n: '03', t: 'Bogen einstellen', d: 'Papier, Schnittmarken, Ränder, Auflösung. Die Vorschau zeigt sofort, wie viele Bogen es werden.' },
{ n: '04', t: 'Drucken', d: 'PDF erzeugen und im Druckdialog „Tatsächliche Größe / 100 %“ wählen. Fertig.' },
];
---
<Base title="Klarbild Drucken — Bilder auf exakte Maße bringen">
<main class="vs">
<header class="held">
<p class="mono-label">Klarbild · Druckmodul</p>
<h1>Bilder auf exakte Maße bringen.<br /><span class="ohne">Ohne KI.</span></h1>
<p class="lead">
Ein Passbild ist 35 × 45 Millimeter. Ein Rahmen ist 13 × 18 Zentimeter. Beides sind Maße,
keine Meinungen — und trotzdem ist es erstaunlich mühsam, ein Foto genau so aus dem Drucker
zu bekommen. Dieses Werkzeug macht daraus einen Handgriff: Zielmaß eintragen, Ausschnitt
wählen, PDF drucken. Millimetergenau, mit Schnittmarken, mehrere Bilder auf einem Blatt.
</p>
<div class="held-marken" aria-hidden="true">
<svg viewBox="0 0 300 120" class="held-svg">
<rect x="8" y="8" width="284" height="104" fill="#FBFBF7" stroke="#D7D6CC" />
<g stroke="#1B3BE0" stroke-width="1">
<path d="M26 22h-10M22 26v-10" /><path d="M120 22h10M124 26v-10" />
<path d="M26 98h-10M22 94v10" /><path d="M120 98h10M124 94v10" />
<path d="M156 22h-10M152 26v-10" /><path d="M282 22h-8M278 26v-10" />
<path d="M156 98h-10M152 94v10" /><path d="M282 98h-8M278 94v10" />
</g>
<rect x="26" y="22" width="94" height="76" fill="#EAEAE3" stroke="#D7D6CC" stroke-width="0.5" />
<rect x="156" y="22" width="120" height="76" fill="#EAEAE3" stroke="#D7D6CC" stroke-width="0.5" />
<text x="30" y="93" font-family="monospace" font-size="7" fill="#6A685D">35 × 45 mm</text>
<text x="160" y="93" font-family="monospace" font-size="7" fill="#6A685D">13 × 18 cm</text>
</svg>
</div>
</header>
<section class="zahlen" aria-label="Kennzahlen">
{daten.map((d) => (
<div class="zahl">
<b>{d.zahl}</b>
<span class="mono-label">{d.label}</span>
<p>{d.fein}</p>
</div>
))}
</section>
<section class="problem">
<h2>Das Problem</h2>
<p>
Der übliche Weg führt über ein Layoutprogramm oder über den Druckertreiber, und beide meinen
es zu gut: „an Seitengröße anpassen“, „einpassen“, „randlos skalieren“. Am Ende liegt ein
Ausdruck vor, der fast stimmt. Für ein Passbild ist „fast“ zu wenig, und für ein
4 × 3-Bildchen ein ganzes DIN-A4-Blatt zu verbrauchen, nur weil es sonst nicht in
100 % Größe auf die Seite passt, ist schlicht Verschwendung.
</p>
<p>
Klarbild Drucken rechnet stattdessen in Millimetern, packt so viele Bilder auf einen Bogen,
wie hineingehen, und legt Schnittmarken dorthin, wo geschnitten wird. Was aus dem Drucker
kommt, hat das Maß, das du eingetragen hast.
</p>
</section>
<section class="muster">
<h2>Ein Bogen, gerechnet</h2>
<p class="muster-lead">
Ein 13 × 18, ein 9 × 13 und sechs Passbilder — auf einem einzigen DIN-A4-Bogen,
alle in 100 % Größe, mit Eckmarken zum Schneiden. Diese Zeichnung ist kein Entwurf:
Sie stammt aus demselben Packer, der auch das PDF erzeugt.
</p>
<div class="muster-halter">
<svg class="muster-svg" viewBox={`-6 -6 ${musterBogen.wMm + 12} ${musterBogen.hMm + 12}`} role="img"
aria-label={`DIN-A4-Bogen mit ${musterAnzahl} Bildern in drei Formaten`}>
<rect x="0" y="0" width={musterBogen.wMm} height={musterBogen.hMm} fill="#FBFBF7" stroke="#D7D6CC" stroke-width="0.4" />
{musterSeite?.placements.map((pl) => (
<g>
<rect x={pl.x} y={pl.y} width={pl.w} height={pl.h}
fill={musterFarbe[pl.specId] || 'oklch(45% 0.23 267 / 0.14)'}
stroke="oklch(45% 0.23 267 / 0.45)" stroke-width="0.3" />
<text x={pl.x + pl.w / 2} y={pl.y + pl.h / 2 + 1.6} text-anchor="middle"
font-family="monospace" font-size="4" fill="#16150F" opacity="0.62">
{kurzMass(pl.w, pl.h)}
</text>
</g>
))}
{musterMarken.map((l) => (
<line x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2} stroke="#1B3BE0" stroke-width="0.25" />
))}
</svg>
<ul class="muster-liste">
<li><i style="background: oklch(45% 0.23 267 / 0.09)"></i> 1 × 13 × 18 cm</li>
<li><i style="background: oklch(45% 0.23 267 / 0.20)"></i> 1 × 9 × 13 cm</li>
<li><i style="background: oklch(45% 0.23 267 / 0.34)"></i> 6 × 35 × 45 mm</li>
<li class="muster-summe">{musterAnzahl} Teile auf {musterPlan.pages.length} Bogen · 5 mm Rand · 8 mm Abstand</li>
</ul>
</div>
</section>
<section class="koennen">
<h2>Was es kann</h2>
<div class="karten">
{koennen.map((k) => (
<article class="kk">
<p class="mono-label">{k.kicker}</p>
<h3>{k.titel}</h3>
<p class="kk-text">{k.text}</p>
<p class="kk-beleg">{k.beleg}</p>
</article>
))}
</div>
</section>
<section class="ablauf">
<h2>In vier Schritten</h2>
<ol class="schritte">
{schritte.map((s) => (
<li><span class="mono-label">{s.n}</span><b>{s.t}</b><p>{s.d}</p></li>
))}
</ol>
</section>
<section class="technik">
<h2>Unter der Haube</h2>
<dl>
<div><dt>Einheit</dt><dd>Millimeter. Pixel entstehen erst beim Rendern (<code>px = mm / 25,4 × dpi</code>), PDF-Punkte über <code>mm / 25,4 × 72</code>.</dd></div>
<div><dt>Rechenkern</dt><dd>Abhängigkeitsfreies TypeScript. Browser-Vorschau und Server-PDF benutzen buchstäblich denselben Code — was du siehst, wird gedruckt.</dd></div>
<div><dt>Packen</dt><dd>Exaktes Raster bei gleichen Maßen, MaxRects (Best Short Side Fit) bei gemischten. Bewertet nach wenigsten Bogen, vollster erster Seite, wenigsten Drehungen.</dd></div>
<div><dt>Bild</dt><dd>sharp in einer einzigen Kette: EXIF-Drehung, Ausschnitt, Skalierung, Rand. Der Zuschnitt wird relativ (0…1) gespeichert und ist damit auflösungsunabhängig.</dd></div>
<div><dt>PDF</dt><dd>pdf-lib. Seitengröße exakt das Bogenmaß, Bilder 1 : 1 platziert, Schnittmarken als Vektorlinien, Klebefalze gestrichelt.</dd></div>
<div><dt>Nachweis</dt><dd>Automatische Tests für die Geometrie plus Rasterprobe der fertigen PDFs bei 254 dpi (= 10 px/mm) mit Kantenmessung.</dd></div>
</dl>
</section>
<section class="offen">
<h2>Selbst betreiben</h2>
<p>
Klarbild läuft auf einem eigenen Server: Astro mit Node, PostgreSQL, eine Warteschlange im
selben Prozess, Dateien lokal oder auf S3. Das Druckmodul kommt ohne jeden externen Dienst
aus — kein Modell, kein Schlüssel, keine Bilder, die das Haus verlassen. Wer nur drucken
will, braucht die KI-Teile gar nicht erst zu konfigurieren.
</p>
<p class="fein">
Diese Seite ist eine Vorstellung, keine Bedienoberfläche — hier lässt sich nichts hochladen
und nichts erzeugen.
</p>
</section>
<footer class="fuss">
<span class="regmark"></span>
<span>Klarbild · Druckmodul</span>
</footer>
</main>
<style>
.vs { max-width: 980px; margin: 0 auto; padding: 0 20px 80px; }
.vs h2 { font-size: 1.5rem; letter-spacing: -0.01em; margin: 0 0 14px; }
.vs section { margin: 64px 0; }
.held { padding: 72px 0 8px; }
.held h1 { font-size: clamp(2.1rem, 6vw, 3.4rem); line-height: 1.08; letter-spacing: -0.03em; margin: 14px 0 20px; }
.ohne { color: var(--accent); }
.lead { font-size: clamp(1rem, 2.2vw, 1.15rem); color: var(--ink-soft); max-width: 62ch; margin: 0; line-height: 1.65; }
.held-marken { margin-top: 34px; }
.held-svg { width: 100%; max-width: 420px; height: auto; display: block; }
.zahlen { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px;
background: var(--line); border: 1px solid var(--line); }
.zahl { background: var(--card); padding: 20px 18px; }
.zahl b { display: block; font-size: 2rem; letter-spacing: -0.03em; line-height: 1.1; }
.zahl .mono-label { display: block; margin: 6px 0 8px; }
.zahl p { margin: 0; font-size: 0.82rem; color: var(--ink-soft); line-height: 1.5; }
.problem p { max-width: 68ch; color: var(--ink-soft); margin: 0 0 14px; }
.muster-lead { max-width: 62ch; color: var(--ink-soft); margin: 0 0 24px; }
.muster-halter { display: grid; grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
gap: 32px; align-items: start; }
.muster-svg { width: 100%; height: auto; display: block; }
.muster-liste { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px;
font-size: 0.9rem; color: var(--ink-soft); }
.muster-liste li { display: flex; align-items: center; gap: 10px; }
.muster-liste i { width: 22px; height: 22px; border: 1px solid oklch(45% 0.23 267 / 0.45); flex: none; }
.muster-summe { font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.08em;
color: var(--soft); padding-top: 12px; border-top: 1px solid var(--line); }
.muster-summe::before { content: none; }
.karten { display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); gap: 16px; }
.kk { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; }
.kk h3 { font-size: 1.12rem; margin: 8px 0 10px; letter-spacing: -0.01em; }
.kk-text { margin: 0; font-size: 0.92rem; color: var(--ink-soft); line-height: 1.62; }
.kk-beleg { margin: 14px 0 0; padding-top: 12px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 0.72rem; color: var(--accent); line-height: 1.5; }
.schritte { list-style: none; padding: 0; margin: 0; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
.schritte li { border-top: 2px solid var(--ink); padding-top: 12px; }
.schritte b { display: block; margin: 8px 0 6px; }
.schritte p { margin: 0; font-size: 0.88rem; color: var(--ink-soft); line-height: 1.6; }
.technik dl { margin: 0; display: grid; gap: 1px; background: var(--line); border: 1px solid var(--line); }
.technik dl > div { background: var(--card); padding: 14px 18px; display: grid;
grid-template-columns: 130px minmax(0, 1fr); gap: 16px; align-items: baseline; }
.technik dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--soft); }
.technik dd { margin: 0; font-size: 0.9rem; color: var(--ink-soft); line-height: 1.6; }
.technik code { font-family: var(--font-mono); font-size: 0.82em; background: var(--paper);
border: 1px solid var(--line); border-radius: 3px; padding: 1px 5px; }
.offen p { max-width: 68ch; color: var(--ink-soft); margin: 0 0 14px; }
.offen .fein { font-size: 0.85rem; color: var(--soft); }
.fuss { display: flex; align-items: center; gap: 10px; padding-top: 28px;
border-top: 1px solid var(--line); font-family: var(--font-mono); font-size: 11px;
letter-spacing: 0.14em; text-transform: uppercase; color: var(--soft); }
@media (max-width: 860px) { .zahlen { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) {
.vs { padding: 0 16px 64px; }
.held { padding: 40px 0 4px; }
.vs section { margin: 44px 0; }
.technik dl > div { grid-template-columns: 1fr; gap: 6px; }
.muster-halter { grid-template-columns: 1fr; gap: 20px; }
}
</style>
</Base>
+27 -2
View File
@@ -10,6 +10,17 @@ export async function startImageWorker(): Promise<void> {
await startWorker(concurrency, handle); await startWorker(concurrency, handle);
} }
/** Monatliches Budget erreicht? (Summe usage.cost im laufenden Kalendermonat.) */
async function budgetExceeded(): Promise<boolean> {
const s = await one<{ monthly_budget: number | null }>('SELECT monthly_budget FROM settings WHERE id=1');
const cap = s?.monthly_budget ? Number(s.monthly_budget) : 0;
if (!cap || cap <= 0) return false;
const row = await one<{ spent: number }>(
`SELECT COALESCE(sum(cost),0)::float AS spent FROM items
WHERE cost IS NOT NULL AND created_at >= date_trunc('month', now())`);
return (row?.spent || 0) >= cap;
}
async function handle(job: GenerateJob): Promise<void> { async function handle(job: GenerateJob): Promise<void> {
// Angehaltene/abgebrochene Aufträge nicht verarbeiten. // Angehaltene/abgebrochene Aufträge nicht verarbeiten.
const j = await one<{ status: string }>('SELECT status FROM jobs WHERE id=$1', [job.jobId]); const j = await one<{ status: string }>('SELECT status FROM jobs WHERE id=$1', [job.jobId]);
@@ -17,6 +28,15 @@ async function handle(job: GenerateJob): Promise<void> {
await query(`UPDATE items SET status='queued' WHERE id=$1 AND status='running'`, [job.itemId]); await query(`UPDATE items SET status='queued' WHERE id=$1 AND status='running'`, [job.itemId]);
return; return;
} }
// Kostendeckel: bei Erreichen Auftrag anhalten, Position zurück in die Schlange.
if (await budgetExceeded()) {
await query(`UPDATE jobs SET status='paused' WHERE id=$1`, [job.jobId]);
await query(`UPDATE items SET status='queued', error_message='Monatsbudget erreicht' WHERE id=$1`, [job.itemId]);
console.error('[worker] Monatsbudget erreicht — Auftrag angehalten', job.jobId);
return;
}
await query(`UPDATE jobs SET status='running' WHERE id=$1 AND status='queued'`, [job.jobId]); await query(`UPDATE jobs SET status='running' WHERE id=$1 AND status='queued'`, [job.jobId]);
try { try {
@@ -52,8 +72,13 @@ async function maybeComplete(jobId: string): Promise<void> {
(SELECT count(*) FROM items WHERE job_id=$1 AND status IN ('queued','running'))::int AS open (SELECT count(*) FROM items WHERE job_id=$1 AND status IN ('queued','running'))::int AS open
FROM jobs WHERE id=$1`, [jobId]); FROM jobs WHERE id=$1`, [jobId]);
if (row && row.open === 0) { if (row && row.open === 0) {
await query(`UPDATE jobs SET status='done', finished_at=now() WHERE id=$1 AND status<>'cancelled'`, // Alles fehlgeschlagen, nichts fertig → Auftrag gilt als fehlgeschlagen (rot).
[jobId]); const finalStatus = row.done === 0 && row.failed > 0 ? 'failed' : 'done';
// Idempotent: nur der erste Übergang löst Auslieferung + Rückmeldung aus.
const done = await query(
`UPDATE jobs SET status=$2, finished_at=now()
WHERE id=$1 AND status NOT IN ('done','failed','cancelled') RETURNING id`, [jobId, finalStatus]);
if (done.length === 0) return;
// Picdrop-Auslieferung für alle offenen Positionen anstoßen. // Picdrop-Auslieferung für alle offenen Positionen anstoßen.
try { await deliverPendingForJob(jobId); } catch (e) { console.error('[worker] Auslieferung:', e); } try { await deliverPendingForJob(jobId); } catch (e) { console.error('[worker] Auslieferung:', e); }
// Telegram-Rückmeldung, wenn der Auftrag aus einem Chat kam. // Telegram-Rückmeldung, wenn der Auftrag aus einem Chat kam.
+320
View File
@@ -0,0 +1,320 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { layout, capacity, cutMarks, effectiveGap, recommendedGap, dpiCheck, type SheetSpec } from '../src/lib/printlayout.ts';
import { parseSizeMm, mmToPx, mmToPt, paperById, photoById, labelMm } from '../src/lib/paper.ts';
import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset } from '../src/lib/printoversize.ts';
const A4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 12, bleedMm: 0, center: true };
test('parseSizeMm versteht Tills Schreibweisen', () => {
assert.deepEqual(parseSizeMm('12x15'), { w: 120, h: 150 });
assert.deepEqual(parseSizeMm('12 × 15 cm'), { w: 120, h: 150 });
assert.deepEqual(parseSizeMm('35x45mm'), { w: 35, h: 45 });
assert.deepEqual(parseSizeMm('5'), { w: 50, h: 50 });
assert.deepEqual(parseSizeMm('4,5x6'), { w: 45, h: 60 });
// a:b ist Breite:Höhe — „4:3" ist quer, „3:4" hochkant.
assert.deepEqual(parseSizeMm('4:3 / 15'), { w: 150, h: 112.5 });
assert.deepEqual(parseSizeMm('3:4 / 15'), { w: 112.5, h: 150 });
assert.deepEqual(parseSizeMm('16:9/30'), { w: 300, h: 168.75 });
assert.equal(parseSizeMm('quatsch'), null);
assert.equal(parseSizeMm('9999x1'), null);
});
test('mm → px ist exakt (Druckmaß-Nachweis)', () => {
assert.equal(mmToPx(100, 300), 1181); // 10 cm
assert.equal(mmToPx(150, 300), 1772); // 15 cm
assert.equal(mmToPx(300, 300), 3543); // 30 cm → wie Poster 30×40
assert.equal(mmToPx(400, 300), 4724);
assert.equal(mmToPx(35, 300), 413); // Passbild
assert.equal(Math.round(mmToPt(210) * 100) / 100, 595.28); // A4 in pt
});
test('Passbild-Bogen: 35×45 mm auf A4 ergibt 20 Stück', () => {
assert.equal(capacity({ id: 'p', wMm: 35, hMm: 45, allowRotate: false }, A4), 20);
});
test('Platzierungen überlappen nicht und bleiben im Satzspiegel', () => {
const sheet: SheetSpec = { ...A4, gapMm: 6 };
const res = layout([
{ id: 'a', wMm: 90, hMm: 130, count: 2 },
{ id: 'b', wMm: 35, hMm: 45, count: 6 },
], sheet);
assert.ok(res.pages.length >= 1);
for (const pg of res.pages) {
for (const p of pg.placements) {
assert.ok(p.x >= sheet.marginMm - 1e-6, 'linker Rand');
assert.ok(p.y >= sheet.marginMm - 1e-6, 'oberer Rand');
assert.ok(p.x + p.w <= sheet.wMm - sheet.marginMm + 1e-6, 'rechter Rand');
assert.ok(p.y + p.h <= sheet.hMm - sheet.marginMm + 1e-6, 'unterer Rand');
}
for (let i = 0; i < pg.placements.length; i++)
for (let j = i + 1; j < pg.placements.length; j++) {
const a = pg.placements[i], b = pg.placements[j];
const overlap = a.x < b.x + b.w - 1e-6 && b.x < a.x + a.w - 1e-6
&& a.y < b.y + b.h - 1e-6 && b.y < a.y + a.h - 1e-6;
assert.ok(!overlap, `Überlappung ${a.specId}#${a.copy} / ${b.specId}#${b.copy}`);
}
}
});
test('Abstand hält die Beschnittzugabe der Nachbarn auseinander', () => {
const sheet: SheetSpec = { ...A4, gapMm: 1, bleedMm: 3 };
assert.equal(effectiveGap(sheet), 6);
const res = layout([{ id: 'a', wMm: 50, hMm: 50, count: 4 }], { ...sheet, gapMm: effectiveGap(sheet) });
const row = res.pages[0].placements.filter((p) => Math.abs(p.y - res.pages[0].placements[0].y) < 1e-6)
.sort((a, b) => a.x - b.x);
assert.ok(row.length >= 2);
assert.ok(row[1].x - (row[0].x + row[0].w) >= 6 - 1e-6);
});
test('Eckmarken liegen außerhalb des Endformats', () => {
const res = layout([{ id: 'a', wMm: 90, hMm: 130, count: 1 }], A4);
const pg = res.pages[0];
const lines = cutMarks(pg, A4, { mode: 'corner', lengthMm: 4, offsetMm: 3 });
assert.equal(lines.length, 8, '4 Ecken × 2 Marken');
const p = pg.placements[0];
for (const l of lines) {
const inside = (x: number, y: number) =>
x > p.x + 1e-6 && x < p.x + p.w - 1e-6 && y > p.y + 1e-6 && y < p.y + p.h - 1e-6;
assert.ok(!inside(l.x1, l.y1) && !inside(l.x2, l.y2), 'Marke ragt ins Bild');
}
});
test('Durchgehende Linien laufen nicht durch andere Bilder', () => {
const sheet: SheetSpec = { ...A4, gapMm: 4 };
const res = layout([{ id: 'a', wMm: 60, hMm: 80, count: 6 }], sheet);
const pg = res.pages[0];
const lines = cutMarks(pg, sheet, { mode: 'grid' });
assert.ok(lines.length > 0);
for (const l of lines) {
for (const p of pg.placements) {
const hitsX = Math.min(l.x1, l.x2) < p.x + p.w - 1e-3 && Math.max(l.x1, l.x2) > p.x + 1e-3;
const hitsY = Math.min(l.y1, l.y2) < p.y + p.h - 1e-3 && Math.max(l.y1, l.y2) > p.y + 1e-3;
assert.ok(!(hitsX && hitsY), 'Linie schneidet ein Motiv');
}
}
});
test('Zu großes Bild landet in unplaced statt im Nirwana', () => {
const res = layout([{ id: 'gross', wMm: 300, hMm: 400, count: 1 }], A4);
assert.equal(res.pages.length, 0);
assert.equal(res.unplaced[0].specId, 'gross');
});
test('Mehr als ein Bogen, wenn die Menge nicht passt', () => {
const res = layout([{ id: 'a', wMm: 35, hMm: 45, count: 25 }], A4);
assert.equal(res.pages.length, 2);
assert.equal(res.pages[0].placements.length, 20);
assert.equal(res.pages[1].placements.length, 5);
});
test('Drehen erlaubt mehr Bilder auf 10×15-Fotopapier', () => {
const foto: SheetSpec = { wMm: 100, hMm: 150, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
const ohne = capacity({ id: 'x', wMm: 100, hMm: 50, allowRotate: false }, foto);
const mit = capacity({ id: 'x', wMm: 50, hMm: 100, allowRotate: true }, foto);
assert.equal(ohne, 3);
assert.ok(mit >= 3);
});
test('Empfohlener Abstand macht Platz für Marken', () => {
assert.equal(recommendedGap('corner', 0, 4, 3), 14);
assert.equal(recommendedGap('none', 3), 6);
assert.equal(recommendedGap('grid', 0), 0);
});
test('dpi-Warnung greift bei zu kleiner Quelle', () => {
assert.equal(dpiCheck(1181, 100, 300).ok, true);
assert.equal(dpiCheck(600, 100, 300).ok, false);
assert.equal(dpiCheck(1181, 100, 300).dpi, 300);
});
test('Formattabellen sind konsistent', () => {
assert.equal(paperById('A4')!.w, 210);
assert.equal(paperById('A3plus')!.h, 483);
assert.equal(photoById('P35x45')!.w, 35);
assert.equal(photoById('S12x15')!.h, 150);
assert.equal(labelMm(35, 45), '35 × 45 mm');
assert.equal(labelMm(120, 150), '12 × 15 cm');
// Nachkommastellen dürfen nicht wegfallen — das Label steht in Fußzeile und Dateiname.
assert.equal(labelMm(112.5, 150), '11,25 × 15 cm');
assert.equal(labelMm(105, 148), '10,5 × 14,8 cm');
});
test('Die gewählte Ausrichtung bleibt, wenn sie nicht mehr Bogen kostet', () => {
// Ein einzelnes Passbild darf nicht quer gelegt werden, nur weil hochkant
// mehr auf den Bogen passen würden.
const res = layout([{ id: 'p', wMm: 35, hMm: 45, count: 1, allowRotate: true }], A4);
assert.equal(res.pages[0].placements[0].rotated, false);
assert.equal(res.pages[0].placements[0].w, 35);
// 25 × 15 cm auf A4 hoch: quer ist es zu breit (250 > 200 mm Nutzbreite),
// gedreht passt es — dann muss gedreht werden.
const gross = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: true }], A4);
assert.equal(gross.pages.length, 1);
assert.equal(gross.pages[0].placements[0].rotated, true);
assert.equal(gross.pages[0].placements[0].w, 150);
// Ohne Dreherlaubnis bleibt es liegen und wird als „passt nicht" gemeldet.
const stur = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: false }], A4);
assert.equal(stur.pages.length, 0);
assert.equal(stur.unplaced.length, 1);
});
/* --- Regressionen aus dem Code-Review vom 18.08.2026 ------------------- */
test('Ohne Angabe ist Drehen erlaubt (allowRotate undefined)', () => {
// Vorher landete das Bild in „unplaced", weil !!undefined === false war.
const res = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1 }], A4);
assert.equal(res.unplaced.length, 0);
assert.equal(res.pages[0].placements[0].rotated, true);
});
test('Auch bei vielen Formaten bekommt jedes eine passende Ausrichtung', () => {
// Notbremse „tooBig" (>5 Formate) darf kein Format still fallen lassen.
const specs = Array.from({ length: 5 }, (_, i) => ({ id: `k${i}`, wMm: 20, hMm: 30, count: 1, allowRotate: true }));
specs.push({ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: true });
const res = layout(specs, { ...A4, gapMm: 4 });
const placed = res.pages.flatMap((p) => p.placements).map((p) => p.specId);
assert.ok(placed.includes('g'), 'das breite Bild muss gedreht platziert werden');
assert.equal(res.unplaced.length, 0);
});
test('unplaced nennt das Bild, das wirklich fehlt', () => {
const specs = [
{ id: 'klein', wMm: 20, hMm: 30, count: 2, allowRotate: false },
{ id: 'riesig', wMm: 250, hMm: 250, count: 3, allowRotate: false },
];
const res = layout(specs, A4);
assert.equal(res.unplaced.length, 1);
assert.equal(res.unplaced[0].specId, 'riesig');
assert.equal(res.unplaced[0].count, 3);
});
test('Eckmarken bleiben auch mit Beschnittzugabe außerhalb des gedruckten Bereichs', () => {
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 10, gapMm: 20, bleedMm: 5, center: true };
const res = layout([{ id: 'a', wMm: 60, hMm: 80, count: 2, allowRotate: false }], sheet);
const pg = res.pages[0];
// Wunschversatz 3 mm ist zu klein für 5 mm Beschnitt — muss angehoben werden.
const lines = cutMarks(pg, sheet, { mode: 'corner', lengthMm: 4, offsetMm: 3, bleedMm: 5 });
assert.ok(lines.length > 0);
for (const l of lines) for (const p of pg.placements) {
const inBleed = (x: number, y: number) =>
x > p.x - 5 + 1e-6 && x < p.x + p.w + 5 - 1e-6 && y > p.y - 5 + 1e-6 && y < p.y + p.h + 5 - 1e-6;
assert.ok(!inBleed(l.x1, l.y1) && !inBleed(l.x2, l.y2), 'Marke liegt im gedruckten Beschnitt');
}
});
test('capacity deckelt nicht mehr bei 200', () => {
const a2: SheetSpec = { wMm: 420, hMm: 594, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
const n = capacity({ id: 'x', wMm: 20, hMm: 30, allowRotate: false }, a2);
assert.equal(n, 20 * 19);
});
test('Entartete Mengen bringen den Packer nicht zum Stehen', () => {
const t0 = process.hrtime.bigint();
const specs = Array.from({ length: 40 }, (_, i) => ({ id: `s${i}`, wMm: 5 + (i % 5), hMm: 6 + (i % 4), count: 12, allowRotate: true }));
const res = layout(specs, { wMm: 2000, hMm: 2000, marginMm: 0, gapMm: 0, bleedMm: 0, center: true });
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
assert.ok(res.pages.length >= 1);
assert.ok(ms < 4000, `Packen dauerte ${Math.round(ms)} ms — zu lange für den Serverprozess`);
});
/* --- Übergroße Bilder (20 × 30 auf A4 & Posterdruck) ------------------- */
test('20 × 30 cm passt nicht auf A4 — aber nur um 3 mm', () => {
const randlos: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
assert.equal(fitsOnSheet(200, 300, randlos, false), false);
const info = overflowPlacement('a', 1, 200, 300, randlos, false);
assert.equal(info.rotated, false);
assert.equal(info.lostLeft, 0);
assert.equal(info.lostRight, 0);
assert.equal(info.lostTop, 1.5); // 300 297 = 3 mm, mittig verteilt
assert.equal(info.lostBottom, 1.5);
assert.equal(info.lostMm, 3);
// mittig: x = (210 200) / 2
assert.equal(info.placement.x, 5);
assert.equal(info.placement.y, -1.5);
});
test('Überstehen dreht, wenn dabei weniger verloren geht', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
// 297 × 200 quer: ungedreht ragt es 87 mm über die Breite, gedreht passt es fast.
const info = overflowPlacement('a', 1, 297, 200, a4, true);
assert.equal(info.rotated, true);
assert.equal(info.lostMm, 0);
});
test('Posterdruck: 30 × 40 cm auf A4 ergibt 2 × 2 Blätter mit Klebefalz', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
const tp = tilePlan(300, 400, a4, 10);
assert.equal(tp.cols, 2);
assert.equal(tp.rows, 2);
assert.equal(tp.tiles.length, 4);
assert.equal(tp.overlapMm, 10);
// Erste Kachel füllt die Nutzfläche, letzte trägt den Rest.
assert.equal(tp.tiles[0].wMm, 200);
assert.equal(tp.tiles[0].hMm, 287);
assert.ok(tp.tiles[0].glueRight && tp.tiles[0].glueBottom);
assert.equal(tp.tiles[3].glueRight, false);
assert.equal(tp.tiles[3].glueBottom, false);
// Die Kacheln decken das ganze Bild ab (rechter/unterer Rand erreicht).
const maxX = Math.max(...tp.tiles.map((t) => t.xMm + t.wMm));
const maxY = Math.max(...tp.tiles.map((t) => t.yMm + t.hMm));
assert.ok(maxX >= 300 - 1e-6, `rechts fehlt etwas: ${maxX}`);
assert.ok(maxY >= 400 - 1e-6, `unten fehlt etwas: ${maxY}`);
});
test('Kacheln überlappen sich wirklich um den Klebefalz', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
const tp = tilePlan(300, 400, a4, 10);
const links = tp.tiles.find((t) => t.col === 0 && t.row === 0)!;
const rechts = tp.tiles.find((t) => t.col === 1 && t.row === 0)!;
const ueberlappung = (links.xMm + links.wMm) - rechts.xMm;
assert.equal(Math.round(ueberlappung * 10) / 10, 10);
});
test('Kachel-Ausschnitt bleibt im Zuschnitt des Nutzers', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
const tp = tilePlan(300, 400, a4, 10);
const crop = { x: 0.1, y: 0.2, w: 0.6, h: 0.5 };
for (const t of tp.tiles) {
const c = tileCrop(crop, t, 300, 400);
assert.ok(c.x >= crop.x - 1e-9 && c.x + c.w <= crop.x + crop.w + 1e-9, 'waagerecht ausgebrochen');
assert.ok(c.y >= crop.y - 1e-9 && c.y + c.h <= crop.y + crop.h + 1e-9, 'senkrecht ausgebrochen');
}
// Die erste Kachel beginnt exakt am Zuschnitt-Anfang.
const erste = tileCrop(crop, tp.tiles[0], 300, 400);
assert.equal(Math.round(erste.x * 1e6) / 1e6, crop.x);
assert.equal(Math.round(erste.y * 1e6) / 1e6, crop.y);
});
test('Ein Bild, das passt, gilt nicht als übergroß', () => {
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
assert.equal(fitsOnSheet(130, 180, a4, false), true);
assert.equal(fitsOnSheet(250, 150, a4, true), true, 'gedreht passt es');
assert.equal(fitsOnSheet(250, 150, a4, false), false, 'ungedreht nicht');
});
test('Posterdruck lässt Platz für die Blattnummer, ohne unten herauszuragen', () => {
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, bleedMm: 0 };
// 5 mm Rand reicht nicht für die 8-pt-Zeile — die Kachel rückt 1 mm nach unten.
assert.equal(noteInset(sheet), 1);
const ts = tileSheet(sheet);
const tp = tilePlan(300, 400, ts, 10);
const oben = (sheet.marginMm || 0) + noteInset(sheet);
for (const t of tp.tiles) {
assert.ok(oben + t.hMm <= sheet.hMm + 1e-6,
`Kachel ${t.row}/${t.col} ragt unten heraus: ${oben + t.hMm} > ${sheet.hMm}`);
assert.ok((sheet.marginMm || 0) + t.wMm <= sheet.wMm + 1e-6);
}
// Die Kacheln decken das Endmaß lückenlos ab.
const rechts = Math.max(...tp.tiles.map((t) => t.xMm + t.wMm));
const unten = Math.max(...tp.tiles.map((t) => t.yMm + t.hMm));
assert.ok(rechts >= 300 - 1e-6 && unten >= 400 - 1e-6, `Abdeckung ${rechts} × ${unten}`);
});
test('Großzügiger Rand verschiebt die Kachel nicht', () => {
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 10, bleedMm: 0 };
assert.equal(noteInset(sheet), 0);
assert.deepEqual(tileSheet(sheet), sheet);
});
+140
View File
@@ -0,0 +1,140 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import sharp from 'sharp';
import { renderCell, coverCrop, containCrop } from '../src/lib/printrender.ts';
import { mmToPx } from '../src/lib/paper.ts';
/** Quergrafik 3:2 mit klar erkennbaren Rändern. */
async function testImage(w = 1200, h = 800): Promise<Buffer> {
return sharp({ create: { width: w, height: h, channels: 3, background: { r: 30, g: 90, b: 220 } } })
.png().toBuffer();
}
test('Zuschneiden liefert exakt das Zielmaß — auch mit Beschnittzugabe', async () => {
const img = await testImage();
for (const bleed of [0, 3]) {
const out = await renderCell(img, null, 35, 45, 300, { ext: 'png', fit: 'cover', bleedMm: bleed });
assert.equal(out.width, mmToPx(35 + 2 * bleed, 300));
assert.equal(out.height, mmToPx(45 + 2 * bleed, 300));
}
});
test('Einpassen liefert exakt das Zielmaß und legt die Randfarbe an', async () => {
const img = await testImage();
for (const bleed of [0, 3]) {
const out = await renderCell(img, null, 35, 45, 300, { ext: 'png', fit: 'contain', bleedMm: bleed, background: '#000000' });
assert.equal(out.width, mmToPx(35 + 2 * bleed, 300), 'Breite');
assert.equal(out.height, mmToPx(45 + 2 * bleed, 300), 'Höhe');
const { data, info } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
const at = (x: number, y: number) => {
const i = (y * info.width + x) * info.channels;
return [data[i], data[i + 1], data[i + 2]];
};
const oben = at(Math.round(info.width / 2), 3);
const mitte = at(Math.round(info.width / 2), Math.round(info.height / 2));
assert.deepEqual(oben, [0, 0, 0], 'oben muss Randfarbe sein');
assert.ok(mitte[2] > 150, 'in der Mitte muss das Bild stehen');
}
});
test('Zuschneiden füllt das Format vollständig aus (kein Rand)', async () => {
const img = await testImage();
const out = await renderCell(img, null, 35, 45, 300, { ext: 'png', fit: 'cover', background: '#000000' });
const { data, info } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
const at = (x: number, y: number) => {
const i = (y * info.width + x) * info.channels;
return [data[i], data[i + 1], data[i + 2]];
};
for (const [x, y] of [[3, 3], [info.width - 4, 3], [3, info.height - 4], [info.width - 4, info.height - 4]])
assert.ok(at(x, y)[2] > 150, 'auch die Ecken tragen Bild');
});
test('Querformat-Ziel aus Hochformat-Quelle bleibt maßhaltig', async () => {
const img = await testImage(800, 1200);
const out = await renderCell(img, null, 150, 100, 300, { ext: 'jpg', fit: 'contain', background: '#ffffff' });
assert.equal(out.width, mmToPx(150, 300));
assert.equal(out.height, mmToPx(100, 300));
});
test('Gedrehte Fassung tauscht Breite und Höhe', async () => {
const img = await testImage();
const out = await renderCell(img, null, 90, 130, 300, { ext: 'png', rotate: true });
assert.equal(out.width, mmToPx(130, 300));
assert.equal(out.height, mmToPx(90, 300));
});
test('cover- und contain-Ausschnitt rechnen gegensätzlich', () => {
const a = 35 / 45; // Hochformat-Ziel
const cov = coverCrop(1200, 800, a); // Querbild
const con = containCrop(1200, 800, a);
assert.ok(cov.w < 1 && cov.h === 1, 'cover schneidet seitlich ab');
assert.ok(con.h > 1 && con.w === 1, 'contain ragt oben/unten hinaus');
assert.ok(con.y < 0, 'der Überstand liegt außerhalb des Bildes');
});
test('Ein Ausschnitt wird exakt übernommen (kein stilles Nachzentrieren)', async () => {
// Linke obere Ecke rot einfärben, dann genau diese Ecke zuschneiden.
const base = await sharp({ create: { width: 1000, height: 1000, channels: 3, background: { r: 0, g: 0, b: 255 } } })
.composite([{ input: await sharp({ create: { width: 200, height: 200, channels: 3, background: { r: 255, g: 0, b: 0 } } }).png().toBuffer(), left: 0, top: 0 }])
.png().toBuffer();
const out = await renderCell(base, { x: 0, y: 0, w: 0.2, h: 0.2 }, 50, 50, 300, { ext: 'png' });
const { data } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
assert.ok(data[0] > 200 && data[2] < 60, 'der gewählte Ausschnitt muss rot sein');
});
/* --- Regressionen aus dem Code-Review vom 18.08.2026 ------------------- */
test('EXIF-Drehung wird angewendet — Handyfotos kommen nicht quer heraus', async () => {
const quer = await sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 0, g: 0, b: 255 } } })
.composite([{ input: await sharp({ create: { width: 80, height: 60, channels: 3, background: { r: 255, g: 0, b: 0 } } }).png().toBuffer(), left: 0, top: 0 }])
.jpeg().toBuffer();
const mitExif = await sharp(quer).withMetadata({ orientation: 6 }).jpeg().toBuffer();
const auto = await sharp(await sharp(mitExif).rotate().toBuffer()).metadata();
const out = await renderCell(mitExif, null, 90, 130, 300, { ext: 'png' });
// renderCell muss mit dem Raster rechnen, das der Browser zeigt.
assert.deepEqual(out.srcPx, [auto.width, auto.height]);
});
test('Extreme Ausschnitte sprengen den Speicher nicht und liefern das Zielmaß', async () => {
const panorama = await sharp({ create: { width: 6000, height: 1500, channels: 3, background: { r: 20, g: 80, b: 200 } } }).jpeg().toBuffer();
for (const [w, h] of [[105, 148], [5, 400]] as [number, number][]) {
const out = await renderCell(panorama, null, w, h, 300, { ext: 'jpg', fit: 'contain', background: '#000000' });
assert.equal(out.width, mmToPx(w, 300));
assert.equal(out.height, mmToPx(h, 300));
}
// Ausschnitt weit außerhalb des Bildes: nur Randfarbe drumherum, kein Riesenpuffer.
const weit = await renderCell(panorama, { x: -3.5, y: -3.5, w: 8, h: 8 }, 35, 45, 300, { ext: 'png', fit: 'contain', background: '#000000' });
assert.equal(weit.width, mmToPx(35, 300));
assert.equal(weit.height, mmToPx(45, 300));
});
test('Gedrehte Fassung mit Beschnittzugabe bleibt maßhaltig', async () => {
const img = await testImage();
const out = await renderCell(img, null, 90, 130, 300, { ext: 'png', rotate: true, bleedMm: 3 });
assert.equal(out.width, mmToPx(130 + 6, 300));
assert.equal(out.height, mmToPx(90 + 6, 300));
});
test('Beschnittzugabe vergrößert das Feld, ohne den Ausschnitt zu verschieben', async () => {
// Farbverlauf, damit jede Position eine eigene Farbe hat.
const w = 1000, h = 1000;
const px = Buffer.alloc(w * h * 3);
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const i = (y * w + x) * 3;
px[i] = Math.round((x / w) * 255); px[i + 1] = Math.round((y / h) * 255); px[i + 2] = 128;
}
const img = await sharp(px, { raw: { width: w, height: h, channels: 3 } }).png().toBuffer();
const crop = { x: 0.2, y: 0.3, w: 0.5, h: 0.4 };
const mitte = async (bleed: number) => {
const out = await renderCell(img, crop, 50, 40, 300, { ext: 'png', bleedMm: bleed });
const { data, info } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
// Mitte der Trimmbox = Mitte des Bildes (der Beschnitt liegt symmetrisch außen).
const i = (Math.round(info.height / 2) * info.width + Math.round(info.width / 2)) * info.channels;
return [data[i], data[i + 1]];
};
const a = await mitte(0), b = await mitte(5);
assert.ok(Math.abs(a[0] - b[0]) <= 2 && Math.abs(a[1] - b[1]) <= 2,
`Trimmbox-Mitte wandert mit Beschnitt: ${a} vs ${b}`);
});