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]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-12 00:30:42 +02:00
1 parent 1acd8ed4b9
commit 8ddf781d9b
7 files changed
+301 -28

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}` });
+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}`));
});
});
}