From d45700a9efccdccbfa82cd3899d9c84d598727a9 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 6 Oct 2026 07:17:31 +0200 Subject: [PATCH] =?UTF-8?q?perf:=20Medien=20aufbereiten=20(pnpm=20media:op?= =?UTF-8?q?timize)=20und=20l=C3=A4nger=20cachen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ursachen der langen Ladezeiten: Reel-Video 1,6 GB in 4K mit Index am Dateiende, Headerbild 15 MB, Vorschaubild 1,3 MB im Original. Neues Skript erzeugt Web-Fassungen (.web.mp4, 1080p, faststart; verkleinerte Bilder), die Seite bevorzugt sie automatisch. Mediendateien mit ?v= werden ein Jahr (immutable) gecacht, optimierte Bilder 30 Tage. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/MEDIEN.md | 15 +++ next.config.ts | 3 + package.json | 2 + pnpm-lock.yaml | 135 ++++++++++++++++++++++++++ pnpm-workspace.yaml | 1 + scripts/optimize-media.ts | 162 +++++++++++++++++++++++++++++++ src/app/media/[...path]/route.ts | 4 +- 7 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 scripts/optimize-media.ts diff --git a/docs/MEDIEN.md b/docs/MEDIEN.md index c3d2d1d..bb18ee7 100644 --- a/docs/MEDIEN.md +++ b/docs/MEDIEN.md @@ -57,6 +57,21 @@ Hochformat 9:16. Das Video startet erst nach Klick und nie von selbst mit Ton. Ein Reel erscheint öffentlich erst, wenn das Video vorhanden ist. Namen und Begleittext stehen in `src/content/site.ts` unter `reels.slots`. Sie dürfen nur mit Freigabe der gezeigten Person eingetragen werden. +## Vor dem Hochladen: Videos und große Bilder aufbereiten + +Videos direkt aus Kamera oder Schnittprogramm sind oft riesig (ein Reel hatte 1,6 GB in 4K) und haben den Index am Dateiende. Der Browser muss dann sehr viel laden, bevor überhaupt etwas läuft. Deshalb einmal aufbereiten: + +```bash +pnpm media:optimize C:\Pfad\zu\meinen-medien +``` + +Das Skript legt daneben einen Ordner `meinen-medien-web` mit gleicher Struktur an: + +- aus jedem Video eine `.web.mp4` (höchstens 1920 px, H.264, sofort abspielbar; `header` ohne Ton) und, falls kein Vorschaubild vorhanden ist, eine `.jpg` +- große Bilder verkleinert (Logo 1600 px, sonst 2560 px längste Seite), kleinere unverändert + +Den Inhalt dieses Ordners ins Medienverzeichnis kopieren. Liegt `.web.mp4` neben dem Original, zeigt die Seite automatisch die Web-Fassung; das Original kann dann auch entfernt werden. Bereits aufbereitete Dateien überspringt das Skript beim nächsten Lauf. + ## Ablage im Cluster Die Dateien kommen auf die SMB-Freigabe hinter dem Volume (siehe `k8s/03-storage.yaml`), zum Beispiel: diff --git a/next.config.ts b/next.config.ts index f65d884..5b1b5ab 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,6 +7,9 @@ const nextConfig: NextConfig = { images: { formats: ["image/avif", "image/webp"], qualities: [75, 85], + // Optimierte Bilder 30 Tage im Browser cachen: Medien-URLs tragen ?v=, + // ein ersetztes Bild hat also ohnehin eine neue Adresse. + minimumCacheTTL: 2_592_000, // Feste Bilder (Unterkunft, Team) liegen in public/images/, austauschbare // im persistenten Medienverzeichnis unter /media/… (src/app/media/[...path]/route.ts). localPatterns: [{ pathname: "/media/**" }, { pathname: "/images/**" }], diff --git a/package.json b/package.json index 58a8202..c15811e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "db:seed": "tsx scripts/seed-dev.ts", "admin:create": "tsx scripts/create-admin.ts", "worker": "tsx scripts/worker.ts", + "media:optimize": "tsx scripts/optimize-media.ts", "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test" @@ -51,6 +52,7 @@ "embedded-postgres": "18.4.0-beta.17", "eslint": "^9.39.5", "eslint-config-next": "16.3.6", + "ffmpeg-static": "5.3.0", "prisma": "7.10.0", "tailwindcss": "4.3.3", "tsx": "4.23.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2214071..020b9a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,6 +248,9 @@ importers: eslint-config-next: specifier: 16.3.6 version: 16.3.6(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + ffmpeg-static: + specifier: 5.3.0 + version: 5.3.0(supports-color@7.2.0) prisma: specifier: 7.10.0 version: 7.10.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3) @@ -421,6 +424,10 @@ packages: '@better-fetch/fetch@1.3.2': resolution: {integrity: sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==} + '@derhuerst/http-basic@8.2.4': + resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==} + engines: {node: '>=6.0.0'} + '@electric-sql/pglite-socket@0.1.3': resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} hasBin: true @@ -1388,6 +1395,9 @@ packages: '@types/lodash@4.17.25': resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + '@types/node@10.17.60': + resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} + '@types/node@22.20.4': resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} @@ -1667,6 +1677,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -1849,6 +1863,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + c12@3.3.4: resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} peerDependencies: @@ -1880,6 +1897,9 @@ packages: caniuse-lite@1.0.30001812: resolution: {integrity: sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1911,6 +1931,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} @@ -2090,6 +2114,10 @@ packages: resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} engines: {node: '>=10.13.0'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2315,6 +2343,10 @@ packages: picomatch: optional: true + ffmpeg-static@5.3.0: + resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==} + engines: {node: '>=16'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2479,6 +2511,13 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + http-response-object@3.0.2: + resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -2499,6 +2538,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -3043,6 +3085,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-cache-control@1.0.1: + resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3175,6 +3220,10 @@ packages: typescript: optional: true + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3211,6 +3260,10 @@ packages: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@5.1.1: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} @@ -3279,6 +3332,9 @@ packages: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -3428,6 +3484,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3533,6 +3592,9 @@ packages: resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript-eslint@8.70.1: resolution: {integrity: sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3567,6 +3629,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + valibot@1.4.2: resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: @@ -3897,6 +3962,13 @@ snapshots: '@better-fetch/fetch@1.3.2': {} + '@derhuerst/http-basic@8.2.4': + dependencies: + caseless: 0.12.0 + concat-stream: 2.0.0 + http-response-object: 3.0.2 + parse-cache-control: 1.0.1 + '@electric-sql/pglite-socket@0.1.3(@electric-sql/pglite@0.4.3)': dependencies: '@electric-sql/pglite': 0.4.3 @@ -4626,6 +4698,8 @@ snapshots: '@types/lodash@4.17.25': {} + '@types/node@10.17.60': {} + '@types/node@22.20.4': dependencies: undici-types: 6.21.0 @@ -4947,6 +5021,12 @@ snapshots: acorn@8.18.0: {} + agent-base@6.0.2(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -5125,6 +5205,8 @@ snapshots: node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.1) + buffer-from@1.1.2: {} + c12@3.3.4: dependencies: chokidar: 5.0.0 @@ -5163,6 +5245,8 @@ snapshots: caniuse-lite@1.0.30001812: {} + caseless@0.12.0: {} + chai@6.2.2: {} chalk@4.1.2: @@ -5192,6 +5276,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + confbox@0.2.4: {} confbox@0.3.1: {} @@ -5366,6 +5457,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + env-paths@2.2.1: {} + env-paths@3.0.0: {} es-abstract-get@1.0.0: @@ -5761,6 +5854,15 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + ffmpeg-static@5.3.0(supports-color@7.2.0): + dependencies: + '@derhuerst/http-basic': 8.2.4 + env-paths: 2.2.1 + https-proxy-agent: 5.0.1(supports-color@7.2.0) + progress: 2.0.3 + transitivePeerDependencies: + - supports-color + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -5924,6 +6026,17 @@ snapshots: dependencies: hermes-estree: 0.25.1 + http-response-object@3.0.2: + dependencies: + '@types/node': 10.17.60 + + https-proxy-agent@5.0.1(supports-color@7.2.0): + dependencies: + agent-base: 6.0.2(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -5939,6 +6052,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -6436,6 +6551,8 @@ snapshots: dependencies: callsites: 3.1.0 + parse-cache-control@1.0.1: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -6548,6 +6665,8 @@ snapshots: - react - react-dom + progress@2.0.3: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -6586,6 +6705,12 @@ snapshots: react@19.3.0: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@5.1.1: {} reflect.getprototypeof@1.0.10: @@ -6672,6 +6797,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -6874,6 +7001,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -6973,6 +7104,8 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typedarray@0.0.6: {} + typescript-eslint@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) @@ -7034,6 +7167,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + valibot@1.4.2(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7bda301..73557b3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ allowBuilds: esbuild: true prisma: true sharp: true + ffmpeg-static: true embedded-postgres: true '@embedded-postgres/windows-x64': true '@embedded-postgres/linux-x64': true diff --git a/scripts/optimize-media.ts b/scripts/optimize-media.ts new file mode 100644 index 0000000..b0f92d4 --- /dev/null +++ b/scripts/optimize-media.ts @@ -0,0 +1,162 @@ +/** + * Bereitet Bilder und Videos fuer das Medienverzeichnis auf, bevor sie + * hochgeladen werden (docs/MEDIEN.md): + * + * pnpm media:optimize [ausgabe-ordner] + * + * - Videos (.mp4, .mov, .m4v, .webm) → ".web.mp4": laengste Seite + * hoechstens 1920 px, H.264, Index am Dateianfang (faststart), damit der + * Browser sofort abspielen kann statt erst die ganze Datei zu laden. + * Headervideos ohne Ton. Fehlt ein Vorschaubild, wird eins erzeugt. + * Die Seite nimmt automatisch die .web.mp4, wenn sie daneben liegt. + * - Bilder groesser als noetig werden verkleinert (Logo 1600 px, sonst + * 2560 px laengste Seite), kleinere unveraendert kopiert. + * + * Ausgabe standardmaessig in "-web" mit gleicher + * Ordnerstruktur; diesen Inhalt ins Medienverzeichnis kopieren. Bereits + * aufbereitete Dateien (neuer als das Original) werden uebersprungen. + */ +import { spawn } from "node:child_process"; +import { copyFile, mkdir, readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import ffmpegPath from "ffmpeg-static"; +import sharp from "sharp"; + +const VIDEO_EXTS = new Set([".mp4", ".mov", ".m4v", ".webm"]); +const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp"]); +const MAX_VIDEO_EDGE = 1920; +const MAX_IMAGE_EDGE = 2560; +const MAX_LOGO_EDGE = 1600; +const POSTER_SECOND = 1; + +async function* walk(dir: string): AsyncGenerator { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) yield* walk(full); + else if (entry.isFile()) yield full; + } +} + +async function isFresh(output: string, input: string): Promise { + try { + return (await stat(output)).mtimeMs >= (await stat(input)).mtimeMs; + } catch { + return false; + } +} + +async function exists(file: string): Promise { + return stat(file).then( + () => true, + () => false, + ); +} + +function ffmpeg(args: string[]): Promise { + const binary = ffmpegPath as unknown as string | null; + if (!binary) throw new Error("ffmpeg-static liefert fuer dieses System kein ffmpeg."); + return new Promise((resolve, reject) => { + const child = spawn(binary, ["-hide_banner", "-loglevel", "error", "-stats", "-y", ...args], { stdio: ["ignore", "inherit", "inherit"] }); + child.on("error", reject); + child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`ffmpeg endete mit Code ${code}`)))); + }); +} + +/** Laengste Seite auf MAX_VIDEO_EDGE begrenzen, nie vergroessern, gerade Masse fuer H.264. */ +const SCALE_FILTER = `scale='if(gte(iw,ih),min(${MAX_VIDEO_EDGE},iw),-2)':'if(gte(iw,ih),-2,min(${MAX_VIDEO_EDGE},ih))'`; + +async function hasPoster(input: string, base: string): Promise { + const dir = path.dirname(input); + const name = path.basename(base); + for (const ext of IMAGE_EXTS) { + if (await exists(path.join(dir, `${name}${ext}`))) return true; + } + return false; +} + +async function optimizeVideo(input: string, outDir: string, base: string): Promise { + const output = path.join(outDir, `${base}.web.mp4`); + const written: string[] = []; + if (!(await isFresh(output, input))) { + const silent = base.toLowerCase() === "header"; + await ffmpeg([ + "-i", input, + "-vf", SCALE_FILTER, + "-c:v", "libx264", "-preset", "slow", "-crf", "23", "-profile:v", "high", "-pix_fmt", "yuv420p", + "-maxrate", "6M", "-bufsize", "12M", + ...(silent ? ["-an"] : ["-c:a", "aac", "-b:a", "128k"]), + "-movflags", "+faststart", + output, + ]); + written.push(output); + } + const poster = path.join(outDir, `${base}.jpg`); + if (!(await hasPoster(input, base)) && !(await isFresh(poster, input))) { + await ffmpeg(["-ss", String(POSTER_SECOND), "-i", input, "-frames:v", "1", "-vf", SCALE_FILTER, "-q:v", "3", poster]); + written.push(poster); + } + return written; +} + +async function optimizeImage(input: string, output: string): Promise { + if (await isFresh(output, input)) return []; + const isLogo = path.basename(input).toLowerCase().startsWith("logo"); + const limit = isLogo ? MAX_LOGO_EDGE : MAX_IMAGE_EDGE; + const meta = await sharp(input).metadata(); + if (Math.max(meta.width ?? 0, meta.height ?? 0) <= limit) { + await copyFile(input, output); + return [output]; + } + const resized = sharp(input).rotate().resize(limit, limit, { fit: "inside", withoutEnlargement: true }); + const ext = path.extname(input).toLowerCase(); + if (ext === ".png") await resized.png({ compressionLevel: 9 }).toFile(output); + else if (ext === ".webp") await resized.webp({ quality: 82 }).toFile(output); + else await resized.jpeg({ quality: 82, mozjpeg: true }).toFile(output); + return [output]; +} + +function megabytes(bytes: number): string { + return `${(bytes / 1_048_576).toFixed(1)} MB`; +} + +async function main(): Promise { + const [inputArg, outputArg] = process.argv.slice(2); + if (!inputArg) { + console.error("Aufruf: pnpm media:optimize [ausgabe-ordner]"); + process.exitCode = 2; + return; + } + const inputDir = path.resolve(inputArg); + const outputDir = path.resolve(outputArg ?? `${inputDir}-web`); + if (outputDir === inputDir) throw new Error("Ausgabe-Ordner muss sich vom Eingabe-Ordner unterscheiden."); + + let before = 0; + let after = 0; + for await (const input of walk(inputDir)) { + const rel = path.relative(inputDir, input); + const ext = path.extname(rel).toLowerCase(); + if (!VIDEO_EXTS.has(ext) && !IMAGE_EXTS.has(ext)) continue; + if (rel.toLowerCase().endsWith(".web.mp4")) continue; + const outDir = path.join(outputDir, path.dirname(rel)); + await mkdir(outDir, { recursive: true }); + console.log(`→ ${rel}`); + const written = VIDEO_EXTS.has(ext) + ? await optimizeVideo(input, outDir, path.basename(rel, path.extname(rel))) + : await optimizeImage(input, path.join(outDir, path.basename(rel))); + if (written.length === 0) { + console.log(" bereits aktuell"); + continue; + } + const inSize = (await stat(input)).size; + const outSize = (await Promise.all(written.map((f) => stat(f)))).reduce((sum, s) => sum + s.size, 0); + before += inSize; + after += outSize; + console.log(` ${megabytes(inSize)} → ${megabytes(outSize)}: ${written.map((f) => path.relative(outputDir, f)).join(", ")}`); + } + console.log(`Fertig: ${megabytes(before)} → ${megabytes(after)}. Inhalt von ${outputDir} ins Medienverzeichnis kopieren.`); +} + +main().catch((error: unknown) => { + console.error(`Abbruch: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/src/app/media/[...path]/route.ts b/src/app/media/[...path]/route.ts index 7239a36..6ebf0f1 100644 --- a/src/app/media/[...path]/route.ts +++ b/src/app/media/[...path]/route.ts @@ -47,7 +47,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ path const headers: Record = { "Content-Type": type, "Accept-Ranges": "bytes", - "Cache-Control": "public, max-age=86400", + // Mit ?v= (site-media.ts) aendert sich die Adresse bei + // jedem Austausch: dann ein Jahr cachen, sonst einen Tag. + "Cache-Control": new URL(request.url).searchParams.has("v") ? "public, max-age=31536000, immutable" : "public, max-age=86400", "X-Content-Type-Options": "nosniff", // SVG (Logo) direkt aufgerufen: keine Skripte, keine Nachladungen. ...(type === "image/svg+xml" ? { "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox" } : {}),