Compare commits

...
2 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Sonnet 5 8ddf781d9b feat: Hero-Video beim Upload automatisch fuers Web komprimieren (ffmpeg)
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m35s
Ein direkt von Kamera/Handy hochgeladenes Video (57 MB) lud spuerbar
langsam: keine Kompression, kein Ton entfernt, und ohne "faststart"
musste der Browser oft die gesamte Datei laden, bevor die Wiedergabe
ueberhaupt beginnen konnte.

Neues lib/video-transcode.ts (ffmpeg-static, per pnpm-workspace.yaml
allowBuilds freigegeben) enkodiert jedes hochgeladene Video jetzt zu
einem web-optimierten MP4 neu: Ton entfernt (Hero-Video ist ohnehin
stumm), auf max. 1920px Breite skaliert, CRF 26 mit Bitratendeckel,
"+faststart" fuer sofortigen Wiedergabestart. Nebeneffekt: ein
erfolgreicher Transcode validiert das Video jetzt genauso vollstaendig
wie reencodeImage() das bei Bildern schon tut (nicht mehr nur
Magic-Bytes). ffmpeg-static-Binary wird in der migrator-Docker-Stage
mitinstalliert (Next.js' Standalone-Tracing erkennt den Pfad hinter dem
pnpm-Store-Symlink nicht zuverlaessig), lokal per npm-Postinstall ohne
manuelle Einrichtung.

Getestet mit synthetischem Testvideo: Transcode laeuft durch, Ausgabe
enthaelt keine Audiospur mehr, ist als H.264/MP4 dekodierbar.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 00:30:42 +02:00
Nick SzaunigandClaude Sonnet 5 1acd8ed4b9 fix: Hero-Verlauf verstaerken, damit Text auf hellem Videomaterial lesbar bleibt
Live-Check des hochgeladenen Hero-Videos zeigte: die Headline lag streckenweise
direkt ueber hellen Honigglas-Etiketten, der bisherige rein vertikale
Verlauf (bg-gradient-to-t, 70/10) verdunkelt dort zu wenig, wo die
Textspalte tatsaechlich sitzt (vertikal mittig links). Zusaetzliche
gleichmaessige 25%-Abdunklung ueber die gesamte Flaeche plus staerkerer,
diagonaler Verlauf (to-tr, 70/25) gezielt hinter der Textspalte - visuell
gegen das echte Video verifiziert (Screenshot-Vergleich alt/neu).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 00:18:25 +02:00
8 changed files with 308 additions and 29 deletions

No files matched your search

+15 -2
View File
@@ -27,13 +27,25 @@ ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholde
# Datenbanken wechseln kann, siehe lib/prisma.ts. # Datenbanken wechseln kann, siehe lib/prisma.ts.
RUN pnpm build RUN pnpm build
# --- Migrator-Tools ----------------------------------------------------- # --- Migrator-Tools + Laufzeit-Zusatzpakete --------------------------------
# Eigenstaendiger, schlanker node_modules-Baum nur mit Prisma-CLI, tsx und # Eigenstaendiger, schlanker node_modules-Baum nur mit Prisma-CLI, tsx und
# den paar Laufzeit-Paketen, die prisma/seed.ts importiert — NICHT # den paar Laufzeit-Paketen, die prisma/seed.ts importiert — NICHT
# next/react/sharp/stripe/alle devDependencies, die der volle builder-Stage # next/react/sharp/stripe/alle devDependencies, die der volle builder-Stage
# oben mitbringt. Wird unten in die runner-Stage kopiert, damit die App sich # oben mitbringt. Wird unten in die runner-Stage kopiert, damit die App sich
# beim Start selbst migrieren/seeden kann (kein separates Migrations-Image/ # beim Start selbst migrieren/seeden kann (kein separates Migrations-Image/
# kein Kubernetes-Job mehr noetig). # kein Kubernetes-Job mehr noetig).
#
# ffmpeg-static (Video-Transcoding, siehe lib/video-transcode.ts) landet
# bewusst HIER statt per Next.js' automatischer Standalone-Output-Tracing
# aus der builder-Stage: dessen heruntergeladene ffmpeg-Binary liegt im
# pnpm-Store hinter einem Symlink, den Next.js' Tracer (anders als bei
# sharp, dafuer gibt es eine Sonderbehandlung) nicht zuverlaessig erkennt,
# UND ein direktes COPY einzelner Pakete aus dem pnpm-Store der
# builder-Stage wuerde denselben Symlink lediglich unaufgeloest (und damit
# kaputt) in die runner-Stage kopieren. Diese Stage installiert stattdessen
# jedes benoetigte Laufzeitpaket ueber ein eigenes, in sich geschlossenes
# "pnpm add" neu — genau das Muster, das fuer Prisma/tsx hier bereits
# funktioniert.
FROM base AS migrator FROM base AS migrator
# pnpm-workspace.yaml zuerst kopieren: pnpm v11 blockt Postinstall-Skripte # pnpm-workspace.yaml zuerst kopieren: pnpm v11 blockt Postinstall-Skripte
# (u.a. @prisma/engines, laedt die Schema-Engine-Binary fuer "migrate # (u.a. @prisma/engines, laedt die Schema-Engine-Binary fuer "migrate
@@ -50,7 +62,8 @@ COPY pnpm-workspace.yaml ./
RUN pnpm add --save-prod \ RUN pnpm add --save-prod \
prisma@^7.9.1 tsx@^4 bcryptjs@^3.0.3 dotenv@^17.4.2 \ prisma@^7.9.1 tsx@^4 bcryptjs@^3.0.3 dotenv@^17.4.2 \
@prisma/client@^7.9.1 @prisma/adapter-pg@^7.9.1 \ @prisma/client@^7.9.1 @prisma/adapter-pg@^7.9.1 \
@prisma/adapter-mariadb@^7.9.1 mariadb@^3.5.3 @prisma/adapter-mariadb@^7.9.1 mariadb@^3.5.3 \
ffmpeg-static@^5.3.0
# --- Runtime -------------------------------------------------------------- # --- Runtime --------------------------------------------------------------
FROM base AS runner FROM base AS runner
+1
View File
@@ -23,6 +23,7 @@
"@prisma/adapter-pg": "^7.9.1", "@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1", "@prisma/client": "^7.9.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"ffmpeg-static": "^5.3.0",
"jose": "^6.2.8", "jose": "^6.2.8",
"mariadb": "^3.5.3", "mariadb": "^3.5.3",
"motion": "^13.0.0", "motion": "^13.0.0",
+135
View File
@@ -24,6 +24,9 @@ importers:
bcryptjs: bcryptjs:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
ffmpeg-static:
specifier: ^5.3.0
version: 5.3.0([email protected])
jose: jose:
specifier: ^6.2.8 specifier: ^6.2.8
version: 6.2.8 version: 6.2.8
@@ -262,6 +265,10 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
'@derhuerst/[email protected]':
resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==}
engines: {node: '>=6.0.0'}
'@electric-sql/[email protected]': '@electric-sql/[email protected]':
resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==} resolution: {integrity: sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==}
hasBin: true hasBin: true
@@ -1229,6 +1236,9 @@ packages:
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==}
'@types/[email protected]':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
@@ -1518,6 +1528,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
[email protected]: [email protected]:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
@@ -1656,6 +1670,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
[email protected]: [email protected]:
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
peerDependencies: peerDependencies:
@@ -1687,6 +1704,9 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==} resolution: {integrity: sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==}
[email protected]:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
[email protected]: [email protected]:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1722,6 +1742,10 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
[email protected]:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
[email protected]: [email protected]:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
@@ -1920,6 +1944,10 @@ packages:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
[email protected]:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
[email protected]: [email protected]:
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -2145,6 +2173,10 @@ packages:
picomatch: picomatch:
optional: true optional: true
[email protected]:
resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==}
engines: {node: '>=16'}
[email protected]: [email protected]:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -2321,6 +2353,13 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==}
[email protected]:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
[email protected]: [email protected]:
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2345,6 +2384,9 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'} engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
[email protected]: [email protected]:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2927,6 +2969,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==}
[email protected]: [email protected]:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
@@ -3072,6 +3117,10 @@ packages:
typescript: typescript:
optional: true optional: true
[email protected]:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
[email protected]: [email protected]:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -3111,6 +3160,10 @@ packages:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
[email protected]: [email protected]:
resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
@@ -3183,6 +3236,9 @@ packages:
resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
[email protected]:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
[email protected]: [email protected]:
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3330,6 +3386,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
[email protected]: [email protected]:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3460,6 +3519,9 @@ packages:
resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
[email protected]: [email protected]:
resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3501,6 +3563,9 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
[email protected]:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
[email protected]: [email protected]:
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
peerDependencies: peerDependencies:
@@ -3860,6 +3925,13 @@ snapshots:
'@csstools/[email protected]': {} '@csstools/[email protected]': {}
'@derhuerst/[email protected]':
dependencies:
caseless: 0.12.0
concat-stream: 2.0.0
http-response-object: 3.0.2
parse-cache-control: 1.0.1
'@electric-sql/[email protected](@electric-sql/[email protected])': '@electric-sql/[email protected](@electric-sql/[email protected])':
dependencies: dependencies:
'@electric-sql/pglite': 0.4.3 '@electric-sql/pglite': 0.4.3
@@ -4614,6 +4686,8 @@ snapshots:
'@types/[email protected]': {} '@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]': '@types/[email protected]':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@@ -4934,6 +5008,12 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]([email protected]):
dependencies:
debug: 4.4.3([email protected])
transitivePeerDependencies:
- supports-color
[email protected]: [email protected]:
dependencies: dependencies:
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
@@ -5094,6 +5174,8 @@ snapshots:
node-releases: 2.0.53 node-releases: 2.0.53
update-browserslist-db: 1.3.0([email protected]) update-browserslist-db: 1.3.0([email protected])
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
chokidar: 5.0.0 chokidar: 5.0.0
@@ -5132,6 +5214,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@@ -5163,6 +5247,13 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.2
typedarray: 0.0.6
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -5343,6 +5434,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@@ -5738,6 +5831,15 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.5 picomatch: 4.0.5
[email protected]([email protected]):
dependencies:
'@derhuerst/http-basic': 8.2.4
env-paths: 2.2.1
https-proxy-agent: 5.0.1([email protected])
progress: 2.0.3
transitivePeerDependencies:
- supports-color
[email protected]: [email protected]:
dependencies: dependencies:
flat-cache: 4.0.1 flat-cache: 4.0.1
@@ -5922,6 +6024,17 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@noble/hashes' - '@noble/hashes'
[email protected]:
dependencies:
'@types/node': 10.17.60
[email protected]([email protected]):
dependencies:
agent-base: 6.0.2([email protected])
debug: 4.4.3([email protected])
transitivePeerDependencies:
- supports-color
[email protected]: [email protected]:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@@ -5939,6 +6052,8 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -6491,6 +6606,8 @@ snapshots:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
entities: 8.0.0 entities: 8.0.0
@@ -6628,6 +6745,8 @@ snapshots:
- react - react
- react-dom - react-dom
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -6668,6 +6787,12 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@@ -6758,6 +6883,8 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
isarray: 2.0.5 isarray: 2.0.5
[email protected]: {}
[email protected]: [email protected]:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -6960,6 +7087,10 @@ snapshots:
define-properties: 1.2.1 define-properties: 1.2.1
es-object-atoms: 1.1.2 es-object-atoms: 1.1.2
[email protected]:
dependencies:
safe-buffer: 5.2.1
[email protected]: [email protected]:
dependencies: dependencies:
ansi-regex: 5.0.1 ansi-regex: 5.0.1
@@ -7082,6 +7213,8 @@ snapshots:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10 reflect.getprototypeof: 1.0.10
[email protected]: {}
[email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]): [email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/[email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected])([email protected]) '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/[email protected]([email protected]([email protected])([email protected]))([email protected])([email protected]))([email protected]([email protected])([email protected]))([email protected])([email protected])
@@ -7153,6 +7286,8 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
[email protected]: {}
[email protected]([email protected]): [email protected]([email protected]):
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
+1
View File
@@ -1,6 +1,7 @@
allowBuilds: allowBuilds:
'@prisma/engines': true '@prisma/engines': true
esbuild: true esbuild: true
ffmpeg-static: true
prisma: true prisma: true
sharp: true sharp: true
unrs-resolver: false unrs-resolver: false
+35 -21
View File
@@ -4,12 +4,8 @@ import path from "node:path";
import { isTrustedOrigin } from "@/lib/csrf"; import { isTrustedOrigin } from "@/lib/csrf";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { import { UPLOAD_DIR, MAX_UPLOAD_VIDEO_BYTES, detectVideoExtension, generateUploadFilename } from "@/lib/uploads";
UPLOAD_DIR, import { transcodeVideoForWeb } from "@/lib/video-transcode";
MAX_UPLOAD_VIDEO_BYTES,
detectVideoExtension,
generateUploadFilename,
} from "@/lib/uploads";
import { requireAdminForApi } from "../_shared"; import { requireAdminForApi } from "../_shared";
// node:fs braucht die Node-Runtime (nicht Edge). // node:fs braucht die Node-Runtime (nicht Edge).
@@ -19,16 +15,21 @@ export const runtime = "nodejs";
* Admin-only Upload-Endpunkt fuer das Hero-Hintergrundvideo (siehe * Admin-only Upload-Endpunkt fuer das Hero-Hintergrundvideo (siehe
* lib/content-registry.ts "video"-Feldtyp, aktuell nur home.hero.video). * lib/content-registry.ts "video"-Feldtyp, aktuell nur home.hero.video).
* Eigene Route statt Erweiterung von app/api/admin/upload/route.ts, weil * Eigene Route statt Erweiterung von app/api/admin/upload/route.ts, weil
* Videos anders validiert werden: sharp kann nur Bilder dekodieren, ein * Videos anders validiert werden: sharp kann nur Bilder dekodieren, hier
* Video wird deshalb NICHT neu enkodiert (kein Pixel-fuer-Pixel-Rebuild wie * uebernimmt ffmpeg (siehe lib/video-transcode.ts transcodeVideoForWeb) die
* bei Bildern moeglich), sondern nur anhand seiner Magic-Bytes als MP4/WebM * Neuenkodierung. Ablauf: (1) Magic-Bytes bestaetigen MP4/WebM (nur
* verifiziert und unveraendert gespeichert. Das ist vertretbar, weil (1) nur * Format-Erkennung, sagt noch nichts ueber vollstaendige Dekodierbarkeit),
* eingeloggte 2FA-Admins diesen Endpunkt erreichen (requireAdminForApi), * (2) ffmpeg enkodiert das Video zu einem web-optimierten MP4 neu (kein Ton,
* (2) ausgelieferte Dateien einen festen, aus der Endung abgeleiteten * begrenzte Aufloesung/Bitrate, "+faststart" fuer sofortigen Wiedergabestart
* Content-Type + "X-Content-Type-Options: nosniff" bekommen (siehe * ohne die ganze Datei laden zu muessen — das war der Hauptgrund fuer lange
* app/uploads/[...path]/route.ts) und daher nie als HTML/Skript interpretiert * Ladezeiten bei einem direkt von Handy/Kamera hochgeladenen Rohvideo), (3)
* werden koennen, und (3) der zufaellige Dateiname + die feste Endungs- * NUR das transkodierte Ergebnis wird gespeichert, nie die Originaldatei —
* Allowlist jede andere Nutzung des Pfads verhindern. * ein erfolgreicher Transcode validiert nebenbei vollstaendig, dass die
* Datei tatsaechlich als Video dekodierbar ist (kein reiner Magic-Byte-
* Vertrauensvorschuss mehr, analog zu reencodeImage() fuer Bilder). Zugriff
* ohnehin nur fuer eingeloggte 2FA-Admins (requireAdminForApi), Auslieferung
* mit festem Content-Type + "X-Content-Type-Options: nosniff" (siehe
* app/uploads/[...path]/route.ts) und zufaelligem Dateinamen.
*/ */
export async function POST(request: Request) { export async function POST(request: Request) {
if (!isTrustedOrigin(request)) { if (!isTrustedOrigin(request)) {
@@ -65,18 +66,31 @@ export async function POST(request: Request) {
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
const extension = detectVideoExtension(buffer); if (!detectVideoExtension(buffer)) {
if (!extension) {
return NextResponse.json( return NextResponse.json(
{ error: "Nicht unterstütztes Videoformat. Erlaubt: MP4, WebM." }, { error: "Nicht unterstütztes Videoformat. Erlaubt: MP4, WebM." },
{ status: 400 } { status: 400 }
); );
} }
const filename = generateUploadFilename(extension); let transcoded: Buffer;
try {
transcoded = await transcodeVideoForWeb(buffer);
} catch (error: unknown) {
console.error("Video-Upload: Transcoding fehlgeschlagen.", error);
return NextResponse.json(
{ error: "Das Video konnte nicht verarbeitet werden." },
{ status: 400 }
);
}
// Ausgabe von transcodeVideoForWeb ist immer MP4 (H.264), unabhaengig vom
// Eingabeformat (MP4 ODER WebM) — deshalb hier fest "mp4" statt der
// urspruenglich erkannten Eingabe-Endung.
const filename = generateUploadFilename("mp4");
try { try {
await mkdir(UPLOAD_DIR, { recursive: true }); await mkdir(UPLOAD_DIR, { recursive: true });
await writeFile(path.join(UPLOAD_DIR, filename), buffer); await writeFile(path.join(UPLOAD_DIR, filename), transcoded);
} catch (error: unknown) { } catch (error: unknown) {
// Siehe Kommentar in app/api/admin/upload/route.ts (identisches Muster). // Siehe Kommentar in app/api/admin/upload/route.ts (identisches Muster).
console.error("Video-Upload: Datei konnte nicht gespeichert werden.", error); console.error("Video-Upload: Datei konnte nicht gespeichert werden.", error);
@@ -90,7 +104,7 @@ export async function POST(request: Request) {
action: "media.upload", action: "media.upload",
entityType: "Upload", entityType: "Upload",
entityId: filename, entityId: filename,
detail: `${extension}, ${Math.ceil(buffer.length / 1024)} KB`, detail: `mp4, ${Math.ceil(buffer.length / 1024)} KB -> ${Math.ceil(transcoded.length / 1024)} KB`,
}); });
return NextResponse.json({ path: `/uploads/${filename}` }); return NextResponse.json({ path: `/uploads/${filename}` });
+7 -1
View File
@@ -72,7 +72,13 @@ export function Hero({
priority priority
/> />
)} )}
<div className="absolute inset-0 flex items-center bg-gradient-to-t from-forest/70 via-forest/10 to-transparent"> {/* Gleichmaessige Abdunklung ueber die gesamte Flaeche — noetig, weil
redaktionell gepflegtes Bild-/Videomaterial (z.B. helle Etiketten,
Himmel) sonst je nach Motiv beliebig hell ausfallen und den Text
unlesbar machen kann. Der gerichtete Verlauf darunter verstaerkt das
gezielt dort, wo die Textspalte tatsaechlich sitzt (unten links). */}
<div className="absolute inset-0 bg-forest/25" />
<div className="absolute inset-0 flex items-center bg-gradient-to-tr from-forest/70 via-forest/25 to-transparent">
<Container> <Container>
<motion.div <motion.div
className="max-w-xl text-cream" className="max-w-xl text-cream"
+6 -5
View File
@@ -36,11 +36,12 @@ export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
* 100 MB: deckt auch ein laengeres oder nicht web-optimiertes Hero-Video * 100 MB: deckt auch ein laengeres oder nicht web-optimiertes Hero-Video
* (z.B. direkt von einer Kamera/einem Handy exportiert, oft 40-80 MB) ab. * (z.B. direkt von einer Kamera/einem Handy exportiert, oft 40-80 MB) ab.
* Grosszuegiger als MAX_UPLOAD_BYTES (Bilder), weil Video-Dateien inhaerent * Grosszuegiger als MAX_UPLOAD_BYTES (Bilder), weil Video-Dateien inhaerent
* groesser sind — Videos werden aber (anders als Bilder) NICHT neu enkodiert * groesser sind. Diese Grenze gilt fuer die ROHDATEI vor dem Transcoding
* (dafuer gibt es hier keine sharp-Entsprechung ohne eine ffmpeg- * (siehe lib/video-transcode.ts transcodeVideoForWeb, aufgerufen in
* Abhaengigkeit), die Groessengrenze ist daher die einzige Bremse gegen * app/api/admin/upload-video/route.ts) — das tatsaechlich gespeicherte,
* uebermaessig grosse Uploads. Muss zum "proxy-body-size" in * web-optimierte Ergebnis ist danach in aller Regel deutlich kleiner. Muss
* k8s/06-ingress.yaml passen (dort grosszuegiger bemessen). * zum "proxy-body-size" in k8s/06-ingress.yaml passen (dort grosszuegiger
* bemessen).
*/ */
export const MAX_UPLOAD_VIDEO_BYTES = 100 * 1024 * 1024; export const MAX_UPLOAD_VIDEO_BYTES = 100 * 1024 * 1024;
+108
View File
@@ -0,0 +1,108 @@
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import ffmpegPath from "ffmpeg-static";
/**
* Harte Obergrenze fuer einen einzelnen ffmpeg-Lauf: verhindert, dass ein
* (versehentlich oder absichtlich) pathologischer Upload einen
* Server-Prozess auf unbestimmte Zeit blockiert. Admin-Uploads sind selten
* (ein Hero-Video), 5 Minuten reichen fuer jedes realistische kurze
* Hintergrundvideo bei "medium"-Preset deutlich.
*/
const TRANSCODE_TIMEOUT_MS = 5 * 60 * 1000;
/**
* Enkodiert ein hochgeladenes Video (MP4 ODER WebM, siehe
* detectVideoExtension in lib/uploads.ts) zu einem fuer die Web-Auslieferung
* optimierten MP4 (H.264/yuv420p) neu. Das ist der Grund, warum ein 57-MB-
* Handy-/Kamera-Export vorher spuerbar langsam lud:
*
* 1. Kein Ton: das Hero-Video spielt immer stumm (siehe hero.tsx `muted`) —
* die Audiospur wird komplett entfernt (-an), spart oft bereits deutlich
* Groesse ohne jeden sichtbaren Qualitaetsverlust.
* 2. Passende Aufloesung statt Rohmaterial: auf max. 1920px Breite skaliert
* (Handy-/Kamera-Exporte sind haeufig hoeher aufgeloest, als eine
* Hintergrundflaeche je zeigen kann).
* 3. Web-taugliche Bitrate statt Kamera-Bitrate (CRF 26 + Bitratendeckel)
* statt der oft sehr hohen Rohbitrate direkt von Kamera/Handy.
* 4. "+faststart": verschiebt den moov-Atom (Inhaltsverzeichnis der Datei)
* an den Dateianfang. Ohne dieses Flag muss der Browser haeufig die
* GESAMTE Datei laden, bevor er ueberhaupt zu spielen beginnen kann —
* das allein erklaerte einen grossen Teil der langsamen ersten
* Wiedergabe, unabhaengig von der Dateigroesse.
*
* Nebeneffekt: ein erfolgreicher Transcode beweist zugleich, dass die Datei
* tatsaechlich vollstaendig als Video dekodierbar ist — schliesst damit die
* gleiche Restluecke, die reencodeImage() (lib/uploads.ts) fuer Bilder schon
* abdeckt (volle Neuvalidierung der Pixel-/Frame-Daten, nicht nur der
* Magic-Bytes am Dateianfang).
*/
export async function transcodeVideoForWeb(buffer: Buffer): Promise<Buffer> {
if (!ffmpegPath) {
throw new Error("ffmpeg-Binary (ffmpeg-static) nicht gefunden.");
}
const dir = await mkdtemp(path.join(tmpdir(), "wabenhain-video-"));
const inputPath = path.join(dir, "input");
const outputPath = path.join(dir, "output.mp4");
try {
await writeFile(inputPath, buffer);
await runFfmpeg(ffmpegPath, [
"-y",
"-i",
inputPath,
"-an",
"-vf",
"scale='min(1920,iw)':-2",
"-c:v",
"libx264",
"-preset",
"medium",
"-crf",
"26",
"-maxrate",
"3M",
"-bufsize",
"6M",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
outputPath,
]);
return await readFile(outputPath);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function runFfmpeg(binary: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn(binary, args);
let stderr = "";
const timer = setTimeout(() => {
proc.kill("SIGKILL");
reject(new Error("ffmpeg-Zeitlimit ueberschritten."));
}, TRANSCODE_TIMEOUT_MS);
proc.stderr.on("data", (chunk: Buffer) => {
// ffmpeg schreibt Fortschritt/Diagnose nach stderr auch bei Erfolg —
// hier nur fuer eine hilfreiche Fehlermeldung im Misserfolgsfall
// gesammelt (auf die letzten paar KB begrenzt), nicht geloggt.
stderr += chunk.toString();
if (stderr.length > 4000) stderr = stderr.slice(-4000);
});
proc.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
proc.on("close", (code) => {
clearTimeout(timer);
if (code === 0) resolve();
else reject(new Error(`ffmpeg beendete sich mit Code ${code}: ${stderr}`));
});
});
}