From d2293f846ec63c481028d619ab9b6ca28c73a543 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:36:20 +0000 Subject: [PATCH] Serveur Rust : Reels (client FFmpeg, file de rendus, Remotion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portage de services/ffmpeg.ts, services/reels/*, ttsSync.ts et routes/reels.ts : même file persistante reel_jobs (FOR UPDATE SKIP LOCKED, tentatives, battement de cœur, synchronisation des colonnes generation_* des posts), mêmes validations (messages zod reproduits, paramètres stockés en JSON camelCase compatibles avec les jobs de l'ancien serveur), mêmes réponses JSON. Le rendu Remotion passe par un nouveau service Node (remotion-service/) : POST /renders, suivi de la progression, téléchargement du MP4 en flux, mêmes réglages d'encodage. Sans REMOTION_RENDERER_URL, repli sur le rendu FFmpeg. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ --- remotion-service/.gitignore | 3 + remotion-service/Dockerfile | 66 + remotion-service/package-lock.json | 2931 +++++++++++++++++++++++++ remotion-service/package.json | 24 + remotion-service/server.mjs | 349 +++ server/src/routes/reels.rs | 466 +++- server/src/services/ffmpeg.rs | 621 +++++- server/src/services/reels/assets.rs | 103 + server/src/services/reels/mod.rs | 39 +- server/src/services/reels/params.rs | 591 +++++ server/src/services/reels/pipeline.rs | 256 +++ server/src/services/reels/publish.rs | 350 +++ server/src/services/reels/queue.rs | 391 ++++ server/src/services/reels/remotion.rs | 346 +++ server/src/services/reels/tts_sync.rs | 109 + 15 files changed, 6628 insertions(+), 17 deletions(-) create mode 100644 remotion-service/.gitignore create mode 100644 remotion-service/Dockerfile create mode 100644 remotion-service/package-lock.json create mode 100644 remotion-service/package.json create mode 100644 remotion-service/server.mjs create mode 100644 server/src/services/reels/assets.rs create mode 100644 server/src/services/reels/params.rs create mode 100644 server/src/services/reels/pipeline.rs create mode 100644 server/src/services/reels/publish.rs create mode 100644 server/src/services/reels/queue.rs create mode 100644 server/src/services/reels/remotion.rs create mode 100644 server/src/services/reels/tts_sync.rs diff --git a/remotion-service/.gitignore b/remotion-service/.gitignore new file mode 100644 index 0000000..8932514 --- /dev/null +++ b/remotion-service/.gitignore @@ -0,0 +1,3 @@ +node_modules +# Bundle construit par `node server.mjs --prepare` +bundle diff --git a/remotion-service/Dockerfile b/remotion-service/Dockerfile new file mode 100644 index 0000000..dbb40b5 --- /dev/null +++ b/remotion-service/Dockerfile @@ -0,0 +1,66 @@ +# Service de rendu Remotion des Reels (sous-titres animés, logo, effet de fin). +# Contexte de build : la racine du dépôt (les compositions vivent dans +# client/src/remotion et importent shared/captions.ts). +# docker build -f remotion-service/Dockerfile . + +# Debian slim — glibc requis par le Chrome headless shell de Remotion +FROM node:20-slim + +# Dépendances du Chrome headless shell et polices de secours +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + fonts-dejavu-core \ + fontconfig \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libcups2 \ + libdbus-1-3 \ + libdrm2 \ + libgbm1 \ + libglib2.0-0 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libpango-1.0-0 \ + libx11-6 \ + libx11-xcb1 \ + libxcb1 \ + libxcomposite1 \ + libxdamage1 \ + libxext6 \ + libxfixes3 \ + libxkbcommon0 \ + libxrandr2 \ + libxshmfence1 \ + libxtst6 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Dépendances (versions épinglées, identiques au package.json racine) +COPY remotion-service/package.json remotion-service/package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund + +# Compositions et module partagé qu'elles importent (@shared/captions). +# La police Montserrat vient du paquet @fontsource/montserrat. +COPY client/src/remotion ./client/src/remotion +COPY shared/captions.ts ./shared/captions.ts +COPY remotion-service/server.mjs ./server.mjs + +# Pré-bundle des compositions et téléchargement du Chrome headless shell +# (équivalent de `npx remotion browser ensure`) : aucun accès réseau au +# premier rendu. +RUN node server.mjs --prepare && rm -rf /tmp/remotion-* + +ENV NODE_ENV=production \ + PORT=8100 \ + OUTPUT_DIR=/tmp/remotion-renders + +# Utilisateur non privilégié ; seuls /tmp et le cache Chrome sont utilisés +RUN chown -R node:node /app +USER node + +EXPOSE 8100 + +CMD ["node", "server.mjs"] diff --git a/remotion-service/package-lock.json b/remotion-service/package-lock.json new file mode 100644 index 0000000..c03d271 --- /dev/null +++ b/remotion-service/package-lock.json @@ -0,0 +1,2931 @@ +{ + "name": "socialflow-remotion-service", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "socialflow-remotion-service", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@fontsource/montserrat": "5.3.0", + "@remotion/bundler": "4.0.438", + "@remotion/renderer": "4.0.438", + "react": "18.3.1", + "react-dom": "18.3.1", + "remotion": "4.0.438" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource/montserrat": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz", + "integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediabunny/aac-encoder": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/@mediabunny/aac-encoder/-/aac-encoder-1.39.2.tgz", + "integrity": "sha512-KD6KADVzAnW7tqhRFGBOX4uaiHbd0Yxvg0lfthj3wJLAEEgEBAvi43w+ZXWeEn54X/jpabrLe4bW/eYFFvlbUA==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/flac-encoder": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/@mediabunny/flac-encoder/-/flac-encoder-1.39.2.tgz", + "integrity": "sha512-VwBr3AzZTPEEPvt4aladZiXwOf3W293eq213zDupGQi/taS8WWNqDd3eBdf8FfvlbXATfbRiycXDKyQ0HlOZaQ==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@mediabunny/mp3-encoder": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/@mediabunny/mp3-encoder/-/mp3-encoder-1.39.2.tgz", + "integrity": "sha512-3rrodrGnUpUP8F2d1aRUl8IvjqK3jegkupbOzvOokooSAO5rXk2Lr5jZe7TnPeiVGiXfmnoJ7s9uyUOHlCd8qw==", + "license": "MPL-2.0", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + }, + "peerDependencies": { + "mediabunny": "^1.0.0" + } + }, + "node_modules/@module-federation/error-codes": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", + "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", + "license": "MIT" + }, + "node_modules/@module-federation/runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", + "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/runtime-core": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-core": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", + "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", + "license": "MIT", + "dependencies": { + "@module-federation/error-codes": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@module-federation/runtime-tools": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", + "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/webpack-bundler-runtime": "0.22.0" + } + }, + "node_modules/@module-federation/sdk": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", + "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", + "license": "MIT" + }, + "node_modules/@module-federation/webpack-bundler-runtime": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", + "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime": "0.22.0", + "@module-federation/sdk": "0.22.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", + "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@remotion/bundler": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/bundler/-/bundler-4.0.438.tgz", + "integrity": "sha512-2CVJhgQvdYwh+ioYMZ5xIA+22NIX1/sARTOqDd9nRzxrueTuHN3/U0fhygdwz6TsoVqM9EuDsP1RPSeUbXURpw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/media-parser": "4.0.438", + "@remotion/studio": "4.0.438", + "@remotion/studio-shared": "4.0.438", + "@rspack/core": "1.7.6", + "@rspack/plugin-react-refresh": "1.6.1", + "css-loader": "5.2.7", + "esbuild": "0.25.0", + "react-refresh": "0.18.0", + "remotion": "4.0.438", + "source-map": "0.7.3", + "style-loader": "4.0.0", + "webpack": "5.105.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/compositor-darwin-arm64": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-arm64/-/compositor-darwin-arm64-4.0.438.tgz", + "integrity": "sha512-iRw31eVGczU9gdvJtdNttX09dy9RyZfO/yI+Vtl6LNMWemKrLLuup2gDbU7i7QVw2a6vUH+cv/2eLUtbKdvQJw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-darwin-x64": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-darwin-x64/-/compositor-darwin-x64-4.0.438.tgz", + "integrity": "sha512-BOupRCn0yYGD1SA5hHcCBoiDb2pPQEkagGFhhefAsJM9mK5blhkRCCCSRMm9kvUCEIUAAEXKZ5NpXgtUFsiqsw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-gnu": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-gnu/-/compositor-linux-arm64-gnu-4.0.438.tgz", + "integrity": "sha512-7S1bDtERk/sQVp6ETKD+Ici0Euq7jo1qaYQQz4eqKqik3S/pPGnOnRdPiArA4W9VR3voCj3asIsuk+A0hDu2hw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-arm64-musl": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-arm64-musl/-/compositor-linux-arm64-musl-4.0.438.tgz", + "integrity": "sha512-n6U9rS3ipTd3pLcKF4WxGbdD+Hte7YgkTpDFfX6GRFZTz41d8q40xMKlgNiGmPPOfLyKtY+Af5K0RKAK6wvIkQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-gnu": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-gnu/-/compositor-linux-x64-gnu-4.0.438.tgz", + "integrity": "sha512-IhEOAYphOLETvq8dWllii201YOteukoS2hCwj4jgv39A5slIbyBxUS6TfuFzel2PghwcJDxNLKzbbJAJ0P89ng==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-linux-x64-musl": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-linux-x64-musl/-/compositor-linux-x64-musl-4.0.438.tgz", + "integrity": "sha512-qOmPquSwDTcP8JkdjbLdnbjZv+5BLRSo3BOqIGIcA5MBPOs2fZGM4MAFtO1Cd7IP6M2rzwawcVzplHbWUjn9FA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@remotion/compositor-win32-x64-msvc": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/compositor-win32-x64-msvc/-/compositor-win32-x64-msvc-4.0.438.tgz", + "integrity": "sha512-uso+PNYVQax2wZekkDsfPQrrRT35wySGf6sC2CbQXiwBeHAtsksZZlPpM/N00Rg227ew9jVit7pevdxwt7AD6A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@remotion/licensing": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/licensing/-/licensing-4.0.438.tgz", + "integrity": "sha512-JYg8ebbVkDJDnKt0BZV7OEptAysKkIWTdRPVGdwPeNw6CMcCooPPDuR7gGHjPtm0Odn/A3EKUopCX3kTvyWTPQ==", + "license": "MIT" + }, + "node_modules/@remotion/media-parser": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/media-parser/-/media-parser-4.0.438.tgz", + "integrity": "sha512-BDqu70nW0BAlQhSn0wYUo/r47MphvYJ0pHz4/UW3PRkiYn3iOIIF9/PO4MM2nomBxirMzGoRfxp0aUbTimTyXg==", + "license": "Remotion License https://remotion.dev/license" + }, + "node_modules/@remotion/media-utils": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/media-utils/-/media-utils-4.0.438.tgz", + "integrity": "sha512-ncXhPkpO2RTEYFhtHmeWgQiXJQ3JLFVx3q7h4N5DtI8CXn0A3lOuzExkUaVYM6Gr8dTwoStLbYnt+WX9Ev6Lxg==", + "license": "MIT", + "dependencies": { + "mediabunny": "1.39.2", + "remotion": "4.0.438" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/player": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/player/-/player-4.0.438.tgz", + "integrity": "sha512-2OT8r0arsjxUEVEa5dNgain7ChbDG0THZglxh1Bo4U8nTSMnuX+1yyF0MVOlmT56yaESfoiGbZvnnpB6e7Ltew==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "remotion": "4.0.438" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/renderer": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/renderer/-/renderer-4.0.438.tgz", + "integrity": "sha512-gTQZ3o8bN7oZIj4aN7hRQ+q5ycOZ1J4TEcGCOXtsLscBbGqiq/dUedDjDWrtdd+VUrPXP3V7bhn7+yWI5/1sHA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@remotion/licensing": "4.0.438", + "@remotion/streaming": "4.0.438", + "execa": "5.1.1", + "extract-zip": "2.0.1", + "remotion": "4.0.438", + "source-map": "^0.8.0-beta.0", + "ws": "8.17.1" + }, + "optionalDependencies": { + "@remotion/compositor-darwin-arm64": "4.0.438", + "@remotion/compositor-darwin-x64": "4.0.438", + "@remotion/compositor-linux-arm64-gnu": "4.0.438", + "@remotion/compositor-linux-arm64-musl": "4.0.438", + "@remotion/compositor-linux-x64-gnu": "4.0.438", + "@remotion/compositor-linux-x64-musl": "4.0.438", + "@remotion/compositor-win32-x64-msvc": "4.0.438" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/renderer/node_modules/source-map": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@remotion/streaming": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/streaming/-/streaming-4.0.438.tgz", + "integrity": "sha512-D0mTdQzpmP9RilnxHCvkTVFuE2FQ0SUw2DzAJQ3KBkBC4tBL6mVUoT289NsqjhNxTLmvZfi4LN85U8kpz03CpQ==", + "license": "MIT" + }, + "node_modules/@remotion/studio": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/studio/-/studio-4.0.438.tgz", + "integrity": "sha512-qGsHigArmTFGrdKKQyWM5WiHwp7fN00W6L1hHB3PXiSyCTGeyHQ5EMgdVlFQL59QXidRMINGKk1pMSR2vzWo8w==", + "license": "MIT", + "dependencies": { + "@remotion/media-utils": "4.0.438", + "@remotion/player": "4.0.438", + "@remotion/renderer": "4.0.438", + "@remotion/studio-shared": "4.0.438", + "@remotion/web-renderer": "4.0.438", + "@remotion/zod-types": "4.0.438", + "mediabunny": "1.39.2", + "memfs": "3.4.3", + "open": "^8.4.2", + "remotion": "4.0.438", + "semver": "7.5.3", + "source-map": "0.7.3", + "zod": "4.3.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remotion/studio-shared": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/studio-shared/-/studio-shared-4.0.438.tgz", + "integrity": "sha512-c/92eFDRCDTltZKEna8J9MWaCpONgfDThzhpp+z8geg0d8HeeYFenvKd1NxH9ihuJg09HZLXTV5u9hUpnUj1wA==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.438" + } + }, + "node_modules/@remotion/web-renderer": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/web-renderer/-/web-renderer-4.0.438.tgz", + "integrity": "sha512-cOGtgqRiD+q77Vj65gy/fo+GK1wA3G5ft2xYLPJZzddmrhhrhibyFglljY2Vu7UaXYIUWZTkGoXwdKchC09UZA==", + "license": "UNLICENSED", + "dependencies": { + "@mediabunny/aac-encoder": "1.39.2", + "@mediabunny/flac-encoder": "1.39.2", + "@mediabunny/mp3-encoder": "1.39.2", + "@remotion/licensing": "4.0.438", + "mediabunny": "1.39.2", + "remotion": "4.0.438" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@remotion/zod-types": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/@remotion/zod-types/-/zod-types-4.0.438.tgz", + "integrity": "sha512-Rif8RJO5JpACl8EPjgvufAeg0nujI5eBgDdFKmiysEtjGq2E6G1nwPcBWSxwQLY8L++9FzY1hidFl7DBjWF9yA==", + "license": "MIT", + "dependencies": { + "remotion": "4.0.438" + }, + "peerDependencies": { + "zod": "4.3.6" + } + }, + "node_modules/@rspack/binding": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.6.tgz", + "integrity": "sha512-/NrEcfo8Gx22hLGysanrV6gHMuqZSxToSci/3M4kzEQtF5cPjfOv5pqeLK/+B6cr56ul/OmE96cCdWcXeVnFjQ==", + "license": "MIT", + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "1.7.6", + "@rspack/binding-darwin-x64": "1.7.6", + "@rspack/binding-linux-arm64-gnu": "1.7.6", + "@rspack/binding-linux-arm64-musl": "1.7.6", + "@rspack/binding-linux-x64-gnu": "1.7.6", + "@rspack/binding-linux-x64-musl": "1.7.6", + "@rspack/binding-wasm32-wasi": "1.7.6", + "@rspack/binding-win32-arm64-msvc": "1.7.6", + "@rspack/binding-win32-ia32-msvc": "1.7.6", + "@rspack/binding-win32-x64-msvc": "1.7.6" + } + }, + "node_modules/@rspack/binding-darwin-arm64": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.6.tgz", + "integrity": "sha512-NZ9AWtB1COLUX1tA9HQQvWpTy07NSFfKBU8A6ylWd5KH8AePZztpNgLLAVPTuNO4CZXYpwcoclf8jG/luJcQdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.6.tgz", + "integrity": "sha512-J2g6xk8ZS7uc024dNTGTHxoFzFovAZIRixUG7PiciLKTMP78svbSSWrmW6N8oAsAkzYfJWwQpVgWfFNRHvYxSw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.6.tgz", + "integrity": "sha512-eQfcsaxhFrv5FmtaA7+O1F9/2yFDNIoPZzV/ZvqvFz5bBXVc4FAm/1fVpBg8Po/kX1h0chBc7Xkpry3cabFW8w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.6.tgz", + "integrity": "sha512-DfQXKiyPIl7i1yECHy4eAkSmlUzzsSAbOjgMuKn7pudsWf483jg0UUYutNgXSlBjc/QSUp7906Cg8oty9OfwPA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.6.tgz", + "integrity": "sha512-NdA+2X3lk2GGrMMnTGyYTzM3pn+zNjaqXqlgKmFBXvjfZqzSsKq3pdD1KHZCd5QHN+Fwvoszj0JFsquEVhE1og==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.6.tgz", + "integrity": "sha512-rEy6MHKob02t/77YNgr6dREyJ0e0tv1X6Xsg8Z5E7rPXead06zefUbfazj4RELYySWnM38ovZyJAkPx/gOn3VA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.6.tgz", + "integrity": "sha512-YupOrz0daSG+YBbCIgpDgzfMM38YpChv+afZpaxx5Ml7xPeAZIIdgWmLHnQ2rts73N2M1NspAiBwV00Xx0N4Vg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "1.0.7" + } + }, + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.6.tgz", + "integrity": "sha512-INj7aVXjBvlZ84kEhSK4kJ484ub0i+BzgnjDWOWM1K+eFYDZjLdAsQSS3fGGXwVc3qKbPIssFfnftATDMTEJHQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.6.tgz", + "integrity": "sha512-lXGvC+z67UMcw58In12h8zCa9IyYRmuptUBMItQJzu+M278aMuD1nETyGLL7e4+OZ2lvrnnBIcjXN1hfw2yRzw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.6.tgz", + "integrity": "sha512-zeUxEc0ZaPpmaYlCeWcjSJUPuRRySiSHN23oJ2Xyw0jsQ01Qm4OScPdr0RhEOFuK/UE+ANyRtDo4zJsY52Hadw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/core": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.6.tgz", + "integrity": "sha512-Iax6UhrfZqJajA778c1d5DBFbSIqPOSrI34kpNIiNpWd8Jq7mFIa+Z60SQb5ZQDZuUxcCZikjz5BxinFjTkg7Q==", + "license": "MIT", + "dependencies": { + "@module-federation/runtime-tools": "0.22.0", + "@rspack/binding": "1.7.6", + "@rspack/lite-tapable": "1.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.1" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@rspack/lite-tapable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", + "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", + "license": "MIT" + }, + "node_modules/@rspack/plugin-react-refresh": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.6.1.tgz", + "integrity": "sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw==", + "license": "MIT", + "dependencies": { + "error-stack-parser": "^2.1.4", + "html-entities": "^2.6.0" + }, + "peerDependencies": { + "react-refresh": ">=0.10.0 <1.0.0", + "webpack-hot-middleware": "2.x" + }, + "peerDependenciesMeta": { + "webpack-hot-middleware": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz", + "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==", + "license": "MIT", + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", + "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.15", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.27.0 || ^5.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", + "license": "ISC" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "license": "Unlicense" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mediabunny": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.39.2.tgz", + "integrity": "sha512-VcrisGRt+OI7tTPrziucJoCIPYIS/DEWY37TqzQVLWSUUHiyvsiRizEypQ3FOlhfIZ4ytAG/Mw4zxfetCTyKUg==", + "license": "MPL-2.0", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@types/dom-mediacapture-transform": "^0.1.11", + "@types/dom-webcodecs": "0.1.13" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/Vanilagy" + } + }, + "node_modules/memfs": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.3.tgz", + "integrity": "sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg==", + "license": "Unlicense", + "dependencies": { + "fs-monkey": "1.0.3" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remotion": { + "version": "4.0.438", + "resolved": "https://registry.npmjs.org/remotion/-/remotion-4.0.438.tgz", + "integrity": "sha512-keG2GxHh81UFV0zFrwRh+yngXHn6C3z/Nohc+ru49M1TlIcaThG/hD2amJNH9otYzpYJxVjNF1t/i1hqcI8OGw==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", + "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.6.0.tgz", + "integrity": "sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz", + "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/remotion-service/package.json b/remotion-service/package.json new file mode 100644 index 0000000..6554871 --- /dev/null +++ b/remotion-service/package.json @@ -0,0 +1,24 @@ +{ + "name": "socialflow-remotion-service", + "version": "1.0.0", + "private": true, + "description": "Service de rendu Remotion des Reels SocialFlow (sous-titres animés, logo, effet de fin)", + "type": "module", + "license": "MIT", + "main": "server.mjs", + "scripts": { + "start": "node server.mjs", + "prepare-render": "node server.mjs --prepare" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "@fontsource/montserrat": "5.3.0", + "@remotion/bundler": "4.0.438", + "@remotion/renderer": "4.0.438", + "react": "18.3.1", + "react-dom": "18.3.1", + "remotion": "4.0.438" + } +} diff --git a/remotion-service/server.mjs b/remotion-service/server.mjs new file mode 100644 index 0000000..8122b8b --- /dev/null +++ b/remotion-service/server.mjs @@ -0,0 +1,349 @@ +#!/usr/bin/env node +/** + * Service de rendu Remotion des Reels SocialFlow. + * + * Le serveur principal (Rust) ne peut pas exécuter Remotion (React + + * Chromium) : il lui confie la composition « ReelVideo » (sous-titres animés, + * logo, effet de fin) avec la vidéo et le son déjà préparés par le service + * FFmpeg, puis récupère le MP4. + * + * API (en-tête X-API-Key obligatoire, variable API_KEY) : + * POST /renders { compositionId, inputProps } → { id } + * GET /renders/:id → { status, progress (0..1), error? } + * GET /renders/:id/file → le MP4 + * DELETE /renders/:id → annule le rendu et supprime le fichier + * GET /health → { status: "ok" } + * + * `node server.mjs --prepare` construit le bundle et télécharge le Chrome + * headless shell (étape de build Docker), puis s'arrête. + */ + +import crypto from "node:crypto"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { bundle } from "@remotion/bundler"; +import { ensureBrowser, makeCancelSignal, renderMedia, selectComposition } from "@remotion/renderer"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +/** Racine contenant client/src/remotion et shared/ : l'image Docker, sinon le dépôt. */ +const SOURCE_ROOT = + process.env.REMOTION_SOURCE_ROOT || + (fs.existsSync(path.join(HERE, "client/src/remotion")) ? HERE : path.resolve(HERE, "..")); +const ENTRY_POINT = path.join(SOURCE_ROOT, "client/src/remotion/index.ts"); +const BUNDLE_DIR = process.env.BUNDLE_DIR || path.join(HERE, "bundle"); +const OUTPUT_DIR = process.env.OUTPUT_DIR || path.join(os.tmpdir(), "remotion-renders"); + +const PORT = Number(process.env.PORT) || 8100; +const API_KEY = process.env.API_KEY || ""; +/** Rendus simultanés (chacun occupe déjà plusieurs onglets Chromium). */ +const MAX_PARALLEL_RENDERS = Math.max(1, Number(process.env.MAX_PARALLEL_RENDERS) || 1); +/** Les props contiennent le logo en data URL. */ +const MAX_BODY_BYTES = 32 * 1024 * 1024; +/** Rendus terminés conservés au plus une heure s'ils ne sont pas supprimés. */ +const RESULT_TTL_MS = 60 * 60_000; + +const log = (...args) => console.log(new Date().toISOString(), ...args); +const warn = (...args) => console.warn(new Date().toISOString(), ...args); + +// --------------------------------------------------------------------------- +// Bundle des compositions +// --------------------------------------------------------------------------- + +function bundleOptions(outDir) { + return { + entryPoint: ENTRY_POINT, + outDir, + // Mêmes alias que Vite : les compositions importent @shared/* + webpackOverride: (config) => ({ + ...config, + resolve: { + ...config.resolve, + alias: { + ...(config.resolve?.alias ?? {}), + "@shared": path.join(SOURCE_ROOT, "shared"), + }, + // Dépendances (react, remotion, polices) installées à côté de ce service + modules: [...(config.resolve?.modules ?? ["node_modules"]), path.join(HERE, "node_modules")], + }, + }), + }; +} + +let bundlePromise = null; + +/** Bundle construit au build Docker, sinon construit une fois par processus. */ +function getBundle() { + if (fs.existsSync(path.join(BUNDLE_DIR, "index.html"))) return Promise.resolve(BUNDLE_DIR); + if (!bundlePromise) { + log("📦 [Remotion] Bundle de", ENTRY_POINT); + bundlePromise = bundle(bundleOptions(null)).catch((error) => { + bundlePromise = null; // réessayer au prochain rendu + throw error; + }); + } + return bundlePromise; +} + +/** Onglets Chromium en parallèle : par défaut la moitié des cœurs, 1 au minimum. */ +function renderConcurrency() { + const configured = Number(process.env.RENDER_CONCURRENCY); + if (Number.isFinite(configured) && configured > 0) return Math.floor(configured); + return Math.max(1, Math.floor(os.cpus().length / 2)); +} + +// --------------------------------------------------------------------------- +// Rendus +// --------------------------------------------------------------------------- + +/** @type {Map void}>} */ +const renders = new Map(); +const waiting = []; +let running = 0; + +function enqueue(compositionId, inputProps) { + const id = crypto.randomUUID(); + const render = { + id, + compositionId, + inputProps, + status: "pending", + progress: 0, + output: path.join(OUTPUT_DIR, `${id}.mp4`), + }; + renders.set(id, render); + waiting.push(render); + pump(); + return render; +} + +function pump() { + while (running < MAX_PARALLEL_RENDERS && waiting.length) { + const render = waiting.shift(); + if (!renders.has(render.id)) continue; // supprimé avant son tour + running++; + run(render).finally(() => { + running--; + pump(); + }); + } +} + +async function run(render) { + render.status = "rendering"; + const startedAt = Date.now(); + const { cancelSignal, cancel } = makeCancelSignal(); + render.cancel = cancel; + try { + const serveUrl = await getBundle(); + const composition = await selectComposition({ + serveUrl, + id: render.compositionId, + inputProps: render.inputProps, + }); + await renderMedia({ + composition, + serveUrl, + inputProps: render.inputProps, + outputLocation: render.output, + codec: "h264", + // Qualité visuelle élevée, conforme aux recommandations Instagram/TikTok + crf: 18, + x264Preset: "medium", + pixelFormat: "yuv420p", + colorSpace: "bt709", + audioCodec: "aac", + audioBitrate: "192k", + concurrency: renderConcurrency(), + cancelSignal, + onProgress: ({ progress }) => { + render.progress = progress; + }, + // Une image lente (décodage vidéo sur une petite machine) ne doit pas faire échouer le rendu + timeoutInMilliseconds: 60_000, + onBrowserLog: (entry) => { + if (entry.type === "error") warn("⚠️ [Remotion] Navigateur :", entry.text.slice(0, 300)); + }, + }); + render.status = "done"; + render.progress = 1; + log(`🎞️ [Remotion] ${render.compositionId} ${render.id} rendu en ${((Date.now() - startedAt) / 1000).toFixed(1)} s`); + } catch (error) { + render.status = "failed"; + render.error = error instanceof Error ? error.message : String(error); + warn(`❌ [Remotion] Rendu ${render.id} en échec :`, render.error); + fs.promises.rm(render.output, { force: true }).catch(() => {}); + } finally { + render.cancel = undefined; + render.finishedAt = Date.now(); + // Les props (logo en data URL) ne servent plus + render.inputProps = null; + } +} + +function remove(id) { + const render = renders.get(id); + if (!render) return; + renders.delete(id); + render.cancel?.(); + fs.promises.rm(render.output, { force: true }).catch(() => {}); +} + +// Purge des rendus oubliés +setInterval(() => { + const now = Date.now(); + for (const render of renders.values()) { + if (render.finishedAt && now - render.finishedAt > RESULT_TTL_MS) remove(render.id); + } +}, 10 * 60_000).unref(); + +// --------------------------------------------------------------------------- +// HTTP +// --------------------------------------------------------------------------- + +function send(res, status, body) { + const data = JSON.stringify(body); + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(data) }); + res.end(data); +} + +function authorized(req) { + const given = Buffer.from(String(req.headers["x-api-key"] ?? "")); + const expected = Buffer.from(API_KEY); + return given.length === expected.length && crypto.timingSafeEqual(given, expected); +} + +function readJson(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on("data", (chunk) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(Object.assign(new Error("Corps de requête trop volumineux"), { status: 413 })); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + try { + resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {}); + } catch { + reject(Object.assign(new Error("JSON invalide"), { status: 400 })); + } + }); + req.on("error", reject); + }); +} + +async function handle(req, res) { + const url = new URL(req.url ?? "/", "http://localhost"); + const parts = url.pathname.split("/").filter(Boolean); + + if (!authorized(req)) return send(res, 401, { error: "Invalid API Key" }); + + if (req.method === "GET" && url.pathname === "/health") { + return send(res, 200, { status: "ok", renders: renders.size, running }); + } + + if (parts[0] !== "renders") return send(res, 404, { error: "Route introuvable" }); + + if (parts.length === 1 && req.method === "POST") { + const body = await readJson(req); + if (typeof body?.compositionId !== "string" || !body.compositionId) { + return send(res, 400, { error: "compositionId requis" }); + } + if (body.inputProps == null || typeof body.inputProps !== "object" || Array.isArray(body.inputProps)) { + return send(res, 400, { error: "inputProps doit être un objet" }); + } + const render = enqueue(body.compositionId, body.inputProps); + log(`🎬 [Remotion] Rendu ${render.id} (${render.compositionId}) en file`); + return send(res, 202, { id: render.id }); + } + + const render = parts[1] ? renders.get(parts[1]) : undefined; + + if (parts.length === 2 && req.method === "DELETE") { + remove(parts[1]); + return send(res, 200, { success: true }); + } + if (!render) return send(res, 404, { error: "Rendu introuvable ou expiré" }); + + if (parts.length === 2 && req.method === "GET") { + return send(res, 200, { + status: render.status, + progress: render.progress, + ...(render.error ? { error: render.error } : {}), + }); + } + + if (parts.length === 3 && parts[2] === "file" && req.method === "GET") { + if (render.status !== "done") return send(res, 409, { error: "Rendu non terminé" }); + let stat; + try { + stat = await fs.promises.stat(render.output); + } catch { + return send(res, 404, { error: "Fichier introuvable ou expiré" }); + } + res.writeHead(200, { "Content-Type": "video/mp4", "Content-Length": stat.size }); + fs.createReadStream(render.output) + .on("error", () => res.destroy()) + .pipe(res); + return; + } + + return send(res, 405, { error: "Méthode non autorisée" }); +} + +// --------------------------------------------------------------------------- +// Démarrage +// --------------------------------------------------------------------------- + +async function prepare() { + log("📦 [Remotion] Pré-bundle de", ENTRY_POINT, "→", BUNDLE_DIR); + await fs.promises.rm(BUNDLE_DIR, { recursive: true, force: true }); + await bundle(bundleOptions(BUNDLE_DIR)); + log("🌐 [Remotion] Téléchargement du Chrome headless shell…"); + await ensureBrowser(); + log("✅ [Remotion] Bundle et navigateur prêts"); +} + +if (process.argv.includes("--prepare")) { + prepare().catch((error) => { + console.error("❌ [Remotion] Préparation impossible :", error); + process.exit(1); + }); +} else { + if (!API_KEY) { + console.error("API_KEY doit être défini : le service refuse de démarrer sans clé."); + process.exit(1); + } + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + + const server = http.createServer((req, res) => { + handle(req, res).catch((error) => { + if (res.headersSent) return res.destroy(); + send(res, error?.status ?? 500, { error: error instanceof Error ? error.message : String(error) }); + }); + }); + server.listen(PORT, "0.0.0.0", () => { + log(`🎬 [Remotion] Service de rendu à l'écoute sur le port ${PORT} (rendus simultanés : ${MAX_PARALLEL_RENDERS})`); + }); + + // Bundle et navigateur prêts avant le premier rendu (sans bloquer le démarrage) + getBundle().catch((error) => warn("⚠️ [Remotion] Bundle impossible :", error.message)); + ensureBrowser().catch((error) => warn("⚠️ [Remotion] Navigateur indisponible :", error.message)); + + const shutdown = () => { + log("Arrêt du service de rendu"); + for (const render of renders.values()) render.cancel?.(); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 5_000).unref(); + }; + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); +} diff --git a/server/src/routes/reels.rs b/server/src/routes/reels.rs index 27dafc2..1bd6849 100644 --- a/server/src/routes/reels.rs +++ b/server/src/routes/reels.rs @@ -1,9 +1,471 @@ -//! STUB — routes à porter (voir `server/routes/reels.ts`). +//! Routes des Reels et de la musique (portage de `server/routes/reels.ts`). +//! Toutes exigent une session. -use axum::Router; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use base64::Engine; +use serde_json::{json, Value}; +use crate::auth::AuthUser; +use crate::db::{NewMusicFavorite, NewPost, PostStatus}; +use crate::error::{ApiResult, AppError, OrApiError}; +use crate::extract::{is_truthy, Body, QueryMap}; +use crate::services::ffmpeg::{self, VoicePreviewOptions}; +use crate::services::openrouter; +use crate::services::reels::assets::{ + qwen_target_from_config, resolve_gemini_api_key, resolve_logo_path, resolve_qwen_target, + resolve_store_name, +}; +use crate::services::reels::params::{parse_tts_preview, parse_video_reel_params, srt_text}; +use crate::services::reels::pipeline::effective_renderer; +use crate::services::reels::queue::{count_active_reel_jobs, enqueue_reel_job}; +use crate::services::reels::tts_sync::estimate_voice_timing; use crate::state::AppState; +use crate::time::Ts; pub fn router() -> Router { Router::new() + // Musique (Jamendo désactivé : listes vides) + .route("/api/music/search", get(music_search)) + .route("/api/music/more", get(music_more)) + .route("/api/music/popular", get(music_popular)) + .route( + "/api/music/favorites", + get(list_favorites).post(add_favorite), + ) + .route("/api/music/favorites/check/{track_id}", get(check_favorite)) + .route("/api/music/favorites/{track_id}", delete(remove_favorite)) + .route("/api/music/{track_id}", get(music_details)) + // Reels + .route("/api/reels/generate-text", post(generate_text)) + .route("/api/reels/voices", get(voices)) + .route("/api/reels/tts-preview", post(tts_preview)) + .route("/api/reels/sync-info", post(sync_info)) + .route("/api/reels", post(create_reel)) + .route("/api/reels/config", get(reels_config)) + .route("/api/reels/ongoing", get(ongoing)) + .route("/api/reels/{id}", delete(delete_reel)) +} + +// --------------------------------------------------------------------------- +// Musique +// --------------------------------------------------------------------------- + +/// `parseInt(value)` : chiffres en tête (signe et espaces tolérés), sinon NaN (null en JSON). +fn js_parse_int(value: &str) -> Value { + let trimmed = value.trim_start(); + let (sign, digits) = match trimmed.strip_prefix('-') { + Some(rest) => (-1, rest), + None => (1, trimmed.strip_prefix('+').unwrap_or(trimmed)), + }; + let numeric: String = digits.chars().take_while(|c| c.is_ascii_digit()).collect(); + match numeric.parse::() { + Ok(n) => json!(sign * n), + Err(_) => Value::Null, + } +} + +fn empty_music_page(query: &QueryMap) -> Json { + Json(json!({ + "tracks": [], + "pagination": { + "offset": js_parse_int(query.get("offset").unwrap_or("0")), + "limit": js_parse_int(query.get("limit").unwrap_or("10")), + "hasMore": false, + }, + })) +} + +async fn music_search(_user: AuthUser, query: QueryMap) -> Json { + empty_music_page(&query) +} + +async fn music_more(_user: AuthUser, query: QueryMap) -> Json { + empty_music_page(&query) +} + +async fn music_popular(_user: AuthUser) -> Json { + Json(json!({ "tracks": [] })) +} + +async fn list_favorites(State(state): State, user: AuthUser) -> ApiResult> { + let favorites = state + .db + .get_music_favorites(&user.id) + .await + .or_500("Erreur lors de la récupération des favoris")?; + let tracks: Vec = favorites + .into_iter() + .map(|f| { + json!({ + "id": f.track_id, + "title": f.title, + "artist": f.artist, + "albumName": f.album_name.unwrap_or_default(), + "duration": f.duration, + "previewUrl": f.preview_url, + "downloadUrl": f.download_url, + "imageUrl": f.image_url.unwrap_or_default(), + "license": f.license.unwrap_or_default(), + }) + }) + .collect(); + Ok(Json(json!({ "tracks": tracks, "isFavorites": true }))) +} + +async fn check_favorite( + State(state): State, + user: AuthUser, + Path(track_id): Path, +) -> ApiResult> { + let is_favorite = state + .db + .is_music_favorite(&user.id, &track_id) + .await + .or_500("Erreur lors de la vérification du favori")?; + Ok(Json(json!({ "isFavorite": is_favorite }))) +} + +/// Valeur texte d'un champ JSON « truthy » (les nombres sont convertis comme en JS). +fn text_field(body: &Value, key: &str) -> Option { + let value = body.get(key); + if !is_truthy(value) { + return None; + } + match value? { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + Value::Bool(b) => Some(b.to_string()), + other => Some(other.to_string()), + } +} + +async fn add_favorite( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + const ERROR: &str = "Erreur lors de l'ajout du favori"; + let (Some(track_id), Some(title)) = (text_field(&body, "trackId"), text_field(&body, "title")) + else { + return Err(AppError::bad_request("trackId et title requis")); + }; + + if state + .db + .is_music_favorite(&user.id, &track_id) + .await + .or_500(ERROR)? + { + return Ok(Json( + json!({ "success": true, "message": "Déjà en favori" }), + )); + } + + let duration = body + .get("duration") + .and_then(Value::as_f64) + .map(|d| d as i32) + .unwrap_or(0); + state + .db + .add_music_favorite(NewMusicFavorite { + user_id: user.id.clone(), + track_id, + title, + artist: text_field(&body, "artist").unwrap_or_default(), + album_name: text_field(&body, "albumName"), + duration, + preview_url: text_field(&body, "previewUrl").unwrap_or_default(), + download_url: text_field(&body, "downloadUrl").unwrap_or_default(), + image_url: text_field(&body, "imageUrl"), + license: text_field(&body, "license"), + }) + .await + .or_500(ERROR)?; + Ok(Json(json!({ "success": true }))) +} + +async fn remove_favorite( + State(state): State, + user: AuthUser, + Path(track_id): Path, +) -> ApiResult> { + state + .db + .remove_music_favorite(&user.id, &track_id) + .await + .or_500("Erreur lors de la suppression du favori")?; + Ok(Json(json!({ "success": true }))) +} + +async fn music_details(_user: AuthUser, Path(_track_id): Path) -> AppError { + AppError::not_found("Musique non trouvée") +} + +// --------------------------------------------------------------------------- +// Reels +// --------------------------------------------------------------------------- + +/// Textes IA pour un Reel (même service que les posts). +async fn generate_text( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + let product_info = body.get("productInfo"); + if !is_truthy(product_info) { + return Err(AppError::bad_request("Informations produit requises")); + } + // Une simple chaîne sert de nom et de description du produit + let product_info = match product_info { + Some(Value::String(text)) => { + let text = text.trim(); + json!({ "name": text, "description": text }) + } + Some(other) => other.clone(), + None => Value::Null, + }; + let model = body.get("model").and_then(Value::as_str); + + let fail = |error: anyhow::Error| { + tracing::error!("❌ Error generating Reel text: {error:#}"); + let (status, message) = openrouter::describe_generation_error(&error); + AppError::new(status, message) + }; + + let texts = openrouter::generate_post_text(&state, &product_info, &user.id, model) + .await + .map_err(fail)?; + let generated = Value::Array(texts); + state + .db + .create_ai_generation(&user.id, &product_info, &generated) + .await + .map_err(|e| fail(e.into()))?; + Ok(Json(json!({ "variants": generated }))) +} + +/// Voix disponibles (dont les voix clonées Qwen) et disponibilité du moteur local. +async fn voices(State(state): State, _user: AuthUser) -> Json { + let result = async { + let config = state.db.get_app_config().await?; + ffmpeg::list_voices(&state, qwen_target_from_config(config.as_ref())).await + } + .await; + match result { + Ok(catalog) => Json(json!({ + "qwenAvailable": catalog.qwen_available, + "qwen": catalog.qwen, + "qwenStatus": catalog.qwen_status, + })), + Err(error) => { + tracing::error!("❌ Error listing voices: {error}"); + Json(json!({ "qwenAvailable": false, "qwen": [], "qwenStatus": null })) + } + } +} + +/// Aperçu de la voix. +async fn tts_preview( + State(state): State, + _user: AuthUser, + Body(body): Body, +) -> ApiResult> { + let params = parse_tts_preview(&body).map_err(AppError::bad_request)?; + let engine = params.tts_engine.as_deref(); + + let options = async { + Ok::<_, anyhow::Error>(VoicePreviewOptions { + voice: params.tts_voice.clone(), + engine: params.tts_engine.clone(), + style: params.tts_style.clone(), + gemini_api_key: resolve_gemini_api_key(&state, engine).await?, + qwen: resolve_qwen_target(&state, engine).await?, + }) + } + .await + .map_err(|error| { + tracing::error!("❌ Error generating TTS preview: {error}"); + AppError::new(StatusCode::BAD_GATEWAY, error.to_string()) + })?; + + let preview = ffmpeg::preview_voice(&state, ¶ms.text, &options) + .await + .map_err(|error| { + tracing::error!("❌ Error generating TTS preview: {error}"); + let status = if error.status == Some(400) { + StatusCode::BAD_REQUEST + } else { + StatusCode::BAD_GATEWAY + }; + AppError::new(status, error.message) + })?; + + Ok(Json(json!({ + "success": true, + "audioBase64": base64::engine::general_purpose::STANDARD.encode(&preview.audio), + "duration": preview.duration, + "words": preview.words, + "engine": preview.engine, + "voice": preview.voice, + "warnings": preview.warnings, + }))) +} + +/// Estimation locale de la durée de la voix : ne déclenche aucune synthèse. +async fn sync_info(_user: AuthUser, Body(body): Body) -> ApiResult> { + let text = body.get("text"); + if !is_truthy(text) { + return Err(AppError::bad_request("Texte requis")); + } + let Some(text) = text.and_then(Value::as_str) else { + return Err(AppError::internal("Erreur de calcul de synchronisation")); + }; + Ok(Json(json!(estimate_voice_timing(text)))) +} + +/// Crée un Reel : le rendu part dans la file persistante. +async fn create_reel( + State(state): State, + user: AuthUser, + Body(body): Body, +) -> ApiResult> { + let mut params = parse_video_reel_params(&body).map_err(AppError::bad_request)?; + + let fail = |message: String| { + tracing::error!("❌ Error initiating Reel: {message}"); + AppError::json( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "success": false, "error": message }), + ) + }; + + // Avec un SRT, le texte libre n'est plus utilisé : la voix lit les sous-titres + if let Some(cues) = ¶ms.srt_cues { + params.overlay_text = None; + if params.description.is_none() { + params.description = Some(srt_text(cues)).filter(|d| !d.is_empty()); + } + } + params.store_name = resolve_store_name( + &state, + &user.id, + params.page_ids.first().map(String::as_str), + ) + .await; + + let waiting = count_active_reel_jobs(&state) + .await + .map_err(|e| fail(e.to_string()))?; + + let scheduled_for = match ¶ms.scheduled_for { + Some(date) => Some(Ts::parse(date).ok_or_else(|| fail("Invalid time value".into()))?), + None => None, + }; + let mut new_post = NewPost::new( + user.id.clone(), + params + .description + .clone() + .or_else(|| params.overlay_text.clone()) + .unwrap_or_default(), + ); + new_post.status = if scheduled_for.is_some() { + PostStatus::Scheduled + } else { + PostStatus::Draft + }; + new_post.scheduled_for = scheduled_for; + new_post.generation_status = Some("pending".into()); + new_post.generation_progress = Some(0); + let post = state + .db + .create_post(new_post) + .await + .map_err(|e| fail(e.to_string()))?; + + let stored = serde_json::to_value(¶ms).map_err(|e| fail(e.to_string()))?; + enqueue_reel_job(&state, "video", &user.id, Some(&post.id), stored) + .await + .map_err(|e| fail(e.to_string()))?; + tracing::info!( + "✨ Reel en file. Post {}, {waiting} job(s) avant lui.", + post.id + ); + + Ok(Json(json!({ + "success": true, + "postId": post.id, + "queued": waiting > 0, + "message": if waiting > 0 { + "File d'attente occupée. Votre vidéo sera traitée dès que possible." + } else { + "Traitement démarré en arrière-plan." + }, + "results": [], + "videoUrl": "", + }))) +} + +/// Configuration des services Reels. +async fn reels_config(State(state): State, _user: AuthUser) -> Json { + let (ffmpeg_configured, logo_url) = + tokio::join!(ffmpeg::health_check(&state), resolve_logo_path(&state)); + Json(json!({ + "jamendo": { "configured": false }, + "freesound": { "configured": false }, + "ffmpeg": { "configured": ffmpeg_configured }, + // Aperçu : logo affiché par le lecteur, moteur de rendu utilisé + "logoUrl": logo_url, + "renderer": effective_renderer(&state), + })) +} + +/// Reels en cours de génération. +async fn ongoing(State(state): State, user: AuthUser) -> ApiResult> { + let posts = state + .db + .get_ongoing_reel_posts(&user.id) + .await + .or_500("Erreur lors de la récupération des reels en cours")?; + Ok(Json(Value::Array(posts))) +} + +/// Supprime un Reel (le post et, en cascade, ses publications et liens médias). +async fn delete_reel( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult> { + const ERROR: &str = "Erreur lors de la suppression du Reel"; + let post = state + .db + .get_post(&id) + .await + .or_500(ERROR)? + .ok_or_else(|| AppError::not_found("Reel non trouvé"))?; + if !user.is_admin() && post.user_id != user.id { + return Err(AppError::forbidden("Non autorisé à supprimer ce Reel")); + } + state.db.delete_post(&id).await.or_500(ERROR)?; + Ok(Json( + json!({ "success": true, "message": "Reel supprimé avec succès" }), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_int_comme_javascript() { + assert_eq!(js_parse_int("10"), json!(10)); + assert_eq!(js_parse_int("12abc"), json!(12)); + assert_eq!(js_parse_int(" -3"), json!(-3)); + assert_eq!(js_parse_int("abc"), Value::Null); + assert_eq!(js_parse_int(""), Value::Null); + } } diff --git a/server/src/services/ffmpeg.rs b/server/src/services/ffmpeg.rs index b771b70..66e8f6a 100644 --- a/server/src/services/ffmpeg.rs +++ b/server/src/services/ffmpeg.rs @@ -1,13 +1,33 @@ //! Client du service FFmpeg (conteneur Python `ffmpeg-service`). //! -//! STUB — à porter depuis `server/services/ffmpeg.ts`. -#![allow(unused_variables)] +//! Le service rend le Reel puis expose le MP4 en téléchargement +//! (GET /files/{job}/output.mp4) : la vidéo ne transite pas en base64 dans du +//! JSON. Adresse et clé viennent de `state.ffmpeg` (modifiables à chaud depuis +//! les paramètres). +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use base64::Engine; +use bytes::Bytes; +use futures::StreamExt; +use reqwest::Method; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; +use tokio::io::AsyncWriteExt; +use crate::services::reels::params::SrtCue; use crate::state::AppState; +/// Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. +const PROCESS_TIMEOUT: Duration = Duration::from_secs(20 * 60); +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60); +/// Qwen (local, CPU) met plusieurs fois la durée de la voix à la générer. +const TTS_TIMEOUT: Duration = Duration::from_secs(15 * 60); +const HEALTH_TIMEOUT: Duration = Duration::from_secs(5); +const VOICES_TIMEOUT: Duration = Duration::from_secs(30); + /// Service Qwen TTS (local ou serveur GPU) réglé dans les paramètres. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -33,12 +53,595 @@ pub struct VoiceCatalog { pub edge: Vec, } -/// Le service répond-il ? -pub async fn health_check(state: &AppState) -> bool { - false +/// Erreur renvoyée par le service, avec son message lisible et son statut HTTP. +#[derive(Debug, Clone, thiserror::Error)] +#[error("{message}")] +pub struct FFmpegServiceError { + pub message: String, + pub status: Option, } -/// Voix disponibles ; `qwen` remplace la cible Qwen configurée côté service. -pub async fn list_voices(state: &AppState, qwen: Option) -> anyhow::Result { - anyhow::bail!("service FFmpeg non implémenté") +impl FFmpegServiceError { + pub fn new(message: impl Into, status: Option) -> Self { + Self { + message: message.into(), + status, + } + } +} + +/// Mot affiché et son intervalle, en secondes depuis le début de la vidéo. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimedWord { + pub text: String, + pub start: f64, + pub end: f64, +} + +/// Options d'un rendu (ou d'une préparation Remotion). `None` = valeur par +/// défaut du service : le champ n'est pas envoyé, comme `undefined` en JSON. +#[derive(Debug, Clone, Default)] +pub struct ReelRenderOptions { + pub text: Option, + /// Sous-titres SRT : remplacent `text`, la voix lit chacun à son instant. + pub srt_cues: Option>, + pub music_url: Option, + pub tts_enabled: Option, + pub tts_voice: Option, + pub tts_engine: Option, + pub tts_style: Option, + pub gemini_api_key: Option, + pub qwen: Option, + pub font_size: Option, + pub music_volume: Option, + pub draw_text: Option, + pub stabilize: Option, + pub watermark_url: Option, + pub store_name: Option, + pub enable_ending_effect: Option, + /// Petit logo pendant la vidéo (le grand logo de fin dépend de `enable_ending_effect`). + pub show_logo: Option, +} + +#[derive(Debug, Clone)] +pub struct ReelRenderResult { + pub video: Bytes, + pub duration: f64, + pub tts_engine: Option, + pub tts_voice: Option, + pub warnings: Vec, +} + +/// Rendu préparé pour Remotion : fichiers déjà écrits sur le disque. +#[derive(Debug, Clone)] +pub struct PreparedReel { + pub video_path: PathBuf, + pub audio_path: Option, + pub total_duration: f64, + pub video_duration: f64, + pub logo_start: Option, + /// Fondu final, calculé par le service pour ne jamais rogner la voix. + pub fade_start: Option, + pub fade_duration: Option, + pub words: Vec, + pub tts_engine: Option, + pub tts_voice: Option, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct VoicePreviewOptions { + pub voice: Option, + pub engine: Option, + pub style: Option, + pub gemini_api_key: Option, + pub qwen: Option, +} + +#[derive(Debug, Clone)] +pub struct VoicePreview { + pub audio: Vec, + pub duration: f64, + pub words: Vec, + pub engine: String, + pub voice: String, + pub warnings: Vec, +} + +// --------------------------------------------------------------------------- +// Appels HTTP +// --------------------------------------------------------------------------- + +struct Endpoint { + url: String, + key: String, +} + +async fn endpoint(state: &AppState) -> Result { + let settings = state.ffmpeg.read().await; + match (&settings.api_url, &settings.api_key) { + (Some(url), Some(key)) if !url.is_empty() && !key.is_empty() => Ok(Endpoint { + url: url.trim_end_matches('/').to_string(), + key: key.clone(), + }), + _ => Err(FFmpegServiceError::new( + "Le service FFmpeg n'est pas configuré (FFMPEG_API_URL / FFMPEG_API_KEY).", + None, + )), + } +} + +fn network_error(path: &str, error: reqwest::Error) -> FFmpegServiceError { + if error.is_timeout() { + FFmpegServiceError::new( + format!("Le service FFmpeg n'a pas répondu à temps ({path})"), + None, + ) + } else { + FFmpegServiceError::new(format!("Service FFmpeg injoignable : {error}"), None) + } +} + +/// Message lisible d'une réponse en erreur : `detail` du JSON FastAPI, sinon le corps. +fn error_detail(body: &str) -> String { + let detail = match serde_json::from_str::(body) { + Ok(Value::Object(map)) => match map.get("detail") { + Some(Value::String(s)) => s.clone(), + Some(Value::Null) | None => body.to_string(), + Some(other) => other.to_string(), + }, + _ => body.to_string(), + }; + detail.chars().take(2000).collect() +} + +async fn call( + state: &AppState, + method: Method, + path: &str, + body: Option<&Value>, + headers: &[(&str, String)], + timeout: Duration, +) -> Result { + let endpoint = endpoint(state).await?; + let mut request = state + .http + .request(method, format!("{}{path}", endpoint.url)) + .header("X-API-Key", &endpoint.key) + .timeout(timeout); + for (name, value) in headers { + request = request.header(*name, value); + } + if let Some(body) = body { + request = request.json(body); + } + let response = request.send().await.map_err(|e| network_error(path, e))?; + let status = response.status(); + if !status.is_success() { + let text = response.text().await.unwrap_or_default(); + return Err(FFmpegServiceError::new( + error_detail(&text), + Some(status.as_u16()), + )); + } + Ok(response) +} + +async fn read_json( + response: reqwest::Response, + path: &str, +) -> Result { + let bytes = response.bytes().await.map_err(|e| network_error(path, e))?; + serde_json::from_slice(&bytes).map_err(|e| { + FFmpegServiceError::new( + format!("Réponse invalide du service FFmpeg ({path}) : {e}"), + None, + ) + }) +} + +/// Le fichier n'est plus utile au service une fois récupéré (sinon purgé plus tard). +fn delete_job_later(state: &AppState, job_id: &str) { + let state = state.clone(); + let path = format!("/jobs/{job_id}"); + tokio::spawn(async move { + let _ = call(&state, Method::DELETE, &path, None, &[], HEALTH_TIMEOUT).await; + }); +} + +fn put>(map: &mut Map, key: &str, value: Option) { + if let Some(value) = value { + map.insert(key.to_string(), value.into()); + } +} + +/// Champs communs à /process-reel et /prepare-reel. +fn base_body(video_url: &str, options: &ReelRenderOptions) -> Map { + let mut body = Map::new(); + body.insert("video_url".into(), video_url.into()); + put(&mut body, "text", options.text.clone()); + if let Some(cues) = &options.srt_cues { + body.insert( + "srt_cues".into(), + serde_json::to_value(cues).unwrap_or(Value::Null), + ); + } + put(&mut body, "music_url", options.music_url.clone()); + body.insert( + "tts_enabled".into(), + options.tts_enabled.unwrap_or(false).into(), + ); + put(&mut body, "tts_voice", options.tts_voice.clone()); + put(&mut body, "tts_engine", options.tts_engine.clone()); + put(&mut body, "tts_style", options.tts_style.clone()); + put(&mut body, "gemini_api_key", options.gemini_api_key.clone()); + put( + &mut body, + "qwen_tts_url", + options.qwen.as_ref().map(|q| q.url.clone()), + ); + put( + &mut body, + "qwen_tts_api_key", + options.qwen.as_ref().and_then(|q| q.api_key.clone()), + ); + body +} + +/// Rend un Reel à partir de l'URL d'une vidéo et renvoie le MP4 produit. +pub async fn render_reel( + state: &AppState, + video_url: &str, + options: &ReelRenderOptions, +) -> Result { + let mut body = base_body(video_url, options); + body.insert("font_size".into(), options.font_size.unwrap_or(64).into()); + body.insert( + "music_volume".into(), + options.music_volume.unwrap_or(0.25).into(), + ); + body.insert("draw_text".into(), options.draw_text.unwrap_or(true).into()); + body.insert( + "stabilize".into(), + options.stabilize.unwrap_or(false).into(), + ); + put(&mut body, "watermark_url", options.watermark_url.clone()); + put(&mut body, "store_name", options.store_name.clone()); + body.insert( + "enable_ending_effect".into(), + options.enable_ending_effect.unwrap_or(true).into(), + ); + body.insert( + "show_watermark".into(), + options.show_logo.unwrap_or(true).into(), + ); + + let tts = if options.tts_enabled.unwrap_or(false) { + format!( + "{}/{}/{}", + options.tts_engine.as_deref().unwrap_or("undefined"), + options.tts_voice.as_deref().unwrap_or("undefined"), + options.tts_style.as_deref().unwrap_or("neutral") + ) + } else { + "false".into() + }; + tracing::info!( + "🎬 Rendu du Reel : vidéo {video_url}, texte {} car., musique {}, voix {tts}", + options + .text + .as_deref() + .map(|t| t.chars().count()) + .unwrap_or(0), + options.music_url.is_some() + ); + + #[derive(Deserialize)] + struct ProcessResponse { + job_id: String, + output_path: String, + #[serde(default)] + duration: f64, + #[serde(default)] + tts_engine: Option, + #[serde(default)] + tts_voice: Option, + #[serde(default)] + warnings: Option>, + } + + let response = call( + state, + Method::POST, + "/process-reel", + Some(&Value::Object(body)), + &[], + PROCESS_TIMEOUT, + ) + .await?; + let data: ProcessResponse = read_json(response, "/process-reel").await?; + + let result = async { + let file = call( + state, + Method::GET, + &data.output_path, + None, + &[], + DOWNLOAD_TIMEOUT, + ) + .await?; + let video = file + .bytes() + .await + .map_err(|e| network_error(&data.output_path, e))?; + let warnings = data.warnings.clone().unwrap_or_default(); + for warning in &warnings { + tracing::warn!("⚠️ [FFmpeg] {warning}"); + } + Ok(ReelRenderResult { + video, + duration: data.duration, + tts_engine: data.tts_engine.clone(), + tts_voice: data.tts_voice.clone(), + warnings, + }) + } + .await; + delete_job_later(state, &data.job_id); + result +} + +/// Prépare un rendu Remotion : image recadrée à la durée finale et piste son +/// finale, écrites dans `target_dir`, plus le minutage des mots. +pub async fn prepare_reel( + state: &AppState, + video_url: &str, + options: &ReelRenderOptions, + has_logo: bool, + target_dir: &Path, +) -> Result { + let mut body = base_body(video_url, options); + body.insert( + "music_volume".into(), + options.music_volume.unwrap_or(0.25).into(), + ); + body.insert("draw_text".into(), options.draw_text.unwrap_or(true).into()); + body.insert( + "stabilize".into(), + options.stabilize.unwrap_or(false).into(), + ); + put(&mut body, "store_name", options.store_name.clone()); + body.insert( + "enable_ending_effect".into(), + options.enable_ending_effect.unwrap_or(true).into(), + ); + body.insert("has_logo".into(), has_logo.into()); + + #[derive(Deserialize)] + struct PrepareResponse { + job_id: String, + video_path: String, + #[serde(default)] + audio_path: Option, + #[serde(default)] + total_duration: f64, + #[serde(default)] + video_duration: f64, + #[serde(default)] + logo_start: Option, + #[serde(default)] + fade_start: Option, + #[serde(default)] + fade_duration: Option, + #[serde(default)] + words: Option>, + #[serde(default)] + tts_engine: Option, + #[serde(default)] + tts_voice: Option, + #[serde(default)] + warnings: Option>, + } + + let response = call( + state, + Method::POST, + "/prepare-reel", + Some(&Value::Object(body)), + &[], + PROCESS_TIMEOUT, + ) + .await?; + let data: PrepareResponse = read_json(response, "/prepare-reel").await?; + + let result = async { + tokio::fs::create_dir_all(target_dir).await.map_err(|e| { + FFmpegServiceError::new(format!("Dossier de rendu inaccessible : {e}"), None) + })?; + let video_path = target_dir.join("video.mp4"); + download_to(state, &data.video_path, &video_path).await?; + let audio_path = match &data.audio_path { + Some(path) => { + let target = target_dir.join("audio.wav"); + download_to(state, path, &target).await?; + Some(target) + } + None => None, + }; + let warnings = data.warnings.clone().unwrap_or_default(); + for warning in &warnings { + tracing::warn!("⚠️ [FFmpeg] {warning}"); + } + Ok(PreparedReel { + video_path, + audio_path, + total_duration: data.total_duration, + video_duration: data.video_duration, + logo_start: data.logo_start, + fade_start: data.fade_start, + fade_duration: data.fade_duration, + words: data.words.clone().unwrap_or_default(), + tts_engine: data.tts_engine.clone(), + tts_voice: data.tts_voice.clone(), + warnings, + }) + } + .await; + delete_job_later(state, &data.job_id); + result +} + +/// Télécharge un fichier du service en flux, sans le charger en mémoire. +async fn download_to( + state: &AppState, + service_path: &str, + target: &Path, +) -> Result<(), FFmpegServiceError> { + let response = call( + state, + Method::GET, + service_path, + None, + &[], + DOWNLOAD_TIMEOUT, + ) + .await?; + let write_error = |e: std::io::Error| { + FFmpegServiceError::new( + format!("Écriture de {} impossible : {e}", target.display()), + None, + ) + }; + let mut file = tokio::fs::File::create(target).await.map_err(write_error)?; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| network_error(service_path, e))?; + file.write_all(&chunk).await.map_err(write_error)?; + } + file.flush().await.map_err(write_error)?; + Ok(()) +} + +/// Le service répond-il ? +pub async fn health_check(state: &AppState) -> bool { + call(state, Method::GET, "/health", None, &[], HEALTH_TIMEOUT) + .await + .is_ok() +} + +/// Voix proposées par moteur ; `qwen` remplace la cible Qwen configurée côté service. +pub async fn list_voices( + state: &AppState, + qwen: Option, +) -> anyhow::Result { + let mut headers: Vec<(&str, String)> = Vec::new(); + if let Some(qwen) = &qwen { + if !qwen.url.is_empty() { + headers.push(("X-Qwen-Url", qwen.url.clone())); + } + if let Some(key) = qwen.api_key.as_ref().filter(|k| !k.is_empty()) { + headers.push(("X-Qwen-Key", key.clone())); + } + } + let response = call( + state, + Method::GET, + "/voices", + None, + &headers, + VOICES_TIMEOUT, + ) + .await?; + Ok(read_json(response, "/voices").await?) +} + +/// Génère la voix seule (aperçu), avec le minutage de chaque mot. +pub async fn preview_voice( + state: &AppState, + text: &str, + options: &VoicePreviewOptions, +) -> Result { + let mut body = Map::new(); + body.insert("text".into(), text.into()); + put(&mut body, "tts_voice", options.voice.clone()); + put(&mut body, "tts_engine", options.engine.clone()); + put(&mut body, "tts_style", options.style.clone()); + put(&mut body, "gemini_api_key", options.gemini_api_key.clone()); + put( + &mut body, + "qwen_tts_url", + options.qwen.as_ref().map(|q| q.url.clone()), + ); + put( + &mut body, + "qwen_tts_api_key", + options.qwen.as_ref().and_then(|q| q.api_key.clone()), + ); + + #[derive(Deserialize)] + struct PreviewResponse { + #[serde(default)] + audio_base64: String, + #[serde(default)] + duration: f64, + #[serde(default)] + words: Option>, + #[serde(default)] + engine: String, + #[serde(default)] + voice: String, + #[serde(default)] + warnings: Option>, + } + + let response = call( + state, + Method::POST, + "/preview-tts", + Some(&Value::Object(body)), + &[], + TTS_TIMEOUT, + ) + .await?; + let data: PreviewResponse = read_json(response, "/preview-tts").await?; + let audio = base64::engine::general_purpose::STANDARD + .decode(data.audio_base64.trim()) + .map_err(|e| FFmpegServiceError::new(format!("Audio de l'aperçu illisible : {e}"), None))?; + Ok(VoicePreview { + audio, + duration: data.duration, + words: data.words.unwrap_or_default(), + engine: data.engine, + voice: data.voice, + warnings: data.warnings.unwrap_or_default(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detail_des_erreurs() { + assert_eq!( + error_detail(r#"{"detail":"Vidéo illisible"}"#), + "Vidéo illisible" + ); + assert_eq!( + error_detail("Internal Server Error"), + "Internal Server Error" + ); + assert_eq!(error_detail(r#"{"other":1}"#), r#"{"other":1}"#); + assert_eq!(error_detail(&"x".repeat(3000)).len(), 2000); + } + + #[test] + fn champs_absents_non_envoyes() { + let body = base_body("http://app/v.mp4", &ReelRenderOptions::default()); + assert_eq!(body.get("tts_enabled"), Some(&Value::Bool(false))); + // tts_engine absent : le service prend sa valeur par défaut (gemini) + assert!(!body.contains_key("tts_engine")); + assert!(!body.contains_key("text")); + } } diff --git a/server/src/services/reels/assets.rs b/server/src/services/reels/assets.rs new file mode 100644 index 0000000..0fe1a85 --- /dev/null +++ b/server/src/services/reels/assets.rs @@ -0,0 +1,103 @@ +//! Résolution des ressources d'un Reel : musique, logo, clé Gemini, service +//! Qwen et nom du magasin. + +use crate::db::AppConfig; +use crate::services::ffmpeg::QwenTarget; +use crate::services::media_store; +use crate::state::AppState; + +/// URL de la musique téléchargeable par le service FFmpeg (réseau Docker +/// interne : l'URL publique HTTPS n'y est pas joignable). +pub async fn resolve_music_url( + state: &AppState, + music_track_id: Option<&str>, + music_url: Option<&str>, +) -> Option { + if let Some(url) = music_url.filter(|u| !u.is_empty()) { + return Some(media_store::resolve_internal_url(state, url)); + } + let id = music_track_id?.strip_prefix("internal_")?; + match state.db.get_audio_track(id).await { + Ok(track) => track.map(|t| media_store::resolve_internal_url(state, &t.url)), + Err(error) => { + tracing::error!("⚠️ [Reels] Piste audio introuvable : {error}"); + None + } + } +} + +/// Chemin relatif (/uploads/...) du logo configuré, s'il existe. +pub async fn resolve_logo_path(state: &AppState) -> Option { + match state.db.get_cloudinary_config().await { + Ok(config) => config + .and_then(|c| c.logo_public_id) + .filter(|id| !id.is_empty()) + .map(|id| media_store::build_url(&id)), + Err(error) => { + tracing::error!("⚠️ [Reels] Configuration du logo illisible : {error}"); + None + } + } +} + +/// Clé Gemini : configuration de l'application, sinon variable d'environnement. +/// Aussi transmise avec Qwen, qui se replie sur Gemini si le service local échoue. +pub async fn resolve_gemini_api_key( + state: &AppState, + tts_engine: Option<&str>, +) -> anyhow::Result> { + if !matches!(tts_engine, Some("gemini") | Some("qwen")) { + return Ok(None); + } + let app_config = state.db.get_app_config().await?; + Ok(app_config + .and_then(|c| c.gemini_api_key) + .or_else(|| state.config.gemini_api_key.clone())) +} + +/// Service Qwen TTS réglé dans les paramètres (sinon ffmpeg-api prend QWEN_TTS_URL). +pub async fn resolve_qwen_target( + state: &AppState, + tts_engine: Option<&str>, +) -> anyhow::Result> { + if tts_engine != Some("qwen") { + return Ok(None); + } + Ok(qwen_target_from_config( + state.db.get_app_config().await?.as_ref(), + )) +} + +pub fn qwen_target_from_config(app_config: Option<&AppConfig>) -> Option { + let config = app_config?; + let url = config.qwen_tts_url.as_ref().filter(|u| !u.is_empty())?; + Some(QwenTarget { + url: url.clone(), + api_key: config.qwen_tts_api_key.clone(), + }) +} + +/// Nom affiché dans l'outro : celui de la page choisie, sinon de la première page de l'utilisateur. +pub async fn resolve_store_name( + state: &AppState, + user_id: &str, + preferred_page_id: Option<&str>, +) -> Option { + let result: Result, sqlx::Error> = async { + if let Some(page_id) = preferred_page_id.filter(|p| !p.is_empty()) { + if let Some(page) = state.db.get_social_page(page_id).await? { + return Ok(Some(page.page_name)); + } + } + let pages = state.db.get_social_pages(user_id).await?; + Ok(pages.into_iter().next().map(|p| p.page_name)) + } + .await; + match result { + Ok(name) => name, + Err(error) => { + tracing::error!("⚠️ [Reels] Nom du magasin introuvable : {error}"); + None + } + } +} diff --git a/server/src/services/reels/mod.rs b/server/src/services/reels/mod.rs index cda7019..10b2bb9 100644 --- a/server/src/services/reels/mod.rs +++ b/server/src/services/reels/mod.rs @@ -1,14 +1,24 @@ -//! Reels : file de rendus persistante, montage, publication. -//! -//! STUB — à porter depuis `server/services/reels/*`. -#![allow(unused_variables)] +//! Reels : file de rendus persistante, montage (service FFmpeg + service +//! Remotion), publication. + +pub mod assets; +pub mod params; +pub mod pipeline; +pub mod publish; +pub mod queue; +pub mod remotion; +pub mod tts_sync; use serde_json::Value; use crate::state::AppState; -/// Démarre le worker de la file `reel_jobs`. -pub fn start(state: AppState) {} +/// Démarre le worker de la file `reel_jobs` : récupération des jobs +/// orphelins, puis traitement de la file (réveil à l'ajout d'un job et +/// toutes les 10 s). +pub fn start(state: AppState) { + queue::start(state); +} /// Adresse http(s) du service Qwen saisie dans les paramètres, sans « / » final ; None si invalide. pub fn normalize_qwen_url(value: Option<&Value>) -> Option { @@ -22,3 +32,20 @@ pub fn normalize_qwen_url(value: Option<&Value>) -> Option { } Some(url.to_string().trim_end_matches('/').to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn adresse_qwen() { + assert_eq!( + normalize_qwen_url(Some(&json!(" http://gpu:8001/ "))).as_deref(), + Some("http://gpu:8001") + ); + assert_eq!(normalize_qwen_url(Some(&json!("ftp://gpu"))), None); + assert_eq!(normalize_qwen_url(Some(&json!(""))), None); + assert_eq!(normalize_qwen_url(None), None); + } +} diff --git a/server/src/services/reels/params.rs b/server/src/services/reels/params.rs new file mode 100644 index 0000000..9bcf7a1 --- /dev/null +++ b/server/src/services/reels/params.rs @@ -0,0 +1,591 @@ +//! Paramètres des rendus de Reels (portage des schémas zod de `shared/reel.ts` +//! et `shared/srt.ts`). +//! +//! Validés à l'entrée des routes et stockés tels quels (JSON camelCase) dans +//! `reel_jobs.params` : un job repris après redémarrage — ou mis en file par +//! l'ancien serveur — retrouve exactement ce que l'utilisateur avait demandé. +//! Les messages reproduisent ceux de zod : seul le premier problème est +//! renvoyé au client (`{ error }`, statut 400). + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub const TTS_ENGINES: [&str; 3] = ["qwen", "gemini", "edge"]; +pub const TTS_STYLES: [&str; 5] = ["neutral", "dynamic", "warm", "calm", "promo"]; +pub const CAPTION_STYLES: [&str; 3] = ["bold", "neon", "minimal"]; +pub const DEFAULT_CAPTION_STYLE: &str = "bold"; +pub const MAX_SRT_CUES: usize = 300; + +/// Sous-titre d'un fichier SRT (secondes). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SrtCue { + pub start: f64, + pub end: f64, + pub text: String, +} + +/// Reel construit à partir d'une vidéo de la médiathèque. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VideoReelParams { + pub video_media_id: String, + pub page_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub music_track_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub music_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub overlay_text: Option, + /// Sous-titres SRT : remplacent le texte libre, la voix lit chacun à son instant. + #[serde(skip_serializing_if = "Option::is_none")] + pub srt_cues: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub tts_enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub tts_voice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tts_engine: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tts_style: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scheduled_for: Option, + /// Publie aussi la vidéo en story sur les pages Facebook (sans effet sur TikTok). + pub also_story: bool, + /// Ancien réglage, ignoré : le minutage vient désormais de la voix elle-même. + #[serde(skip_serializing_if = "Option::is_none")] + pub word_duration: Option, + pub font_size: i64, + pub music_volume: f64, + pub draw_text: bool, + pub caption_style: String, + pub stabilize: bool, + /// Petit logo pendant la vidéo ; le grand logo de fin dépend de `enable_ending_effect`. + pub show_logo: bool, + pub enable_ending_effect: bool, + /// Déterminé par le serveur à partir de la première page, jamais par le client. + #[serde(skip_serializing_if = "Option::is_none")] + pub store_name: Option, +} + +/// Aperçu de la voix. +#[derive(Debug, Clone, PartialEq)] +pub struct TtsPreviewParams { + pub text: String, + pub tts_voice: Option, + pub tts_engine: Option, + pub tts_style: Option, +} + +/// Texte complet du SRT (description de la publication, aperçu de la voix). +pub fn srt_text(cues: &[SrtCue]) -> String { + cues.iter() + .map(|c| c.text.as_str()) + .collect::>() + .join(" ") +} + +// --------------------------------------------------------------------------- +// Petit équivalent de zod : chaque fonction renvoie le premier problème +// rencontré, avec le message par défaut de zod v3 (ou le message personnalisé). +// --------------------------------------------------------------------------- + +type Issue = String; + +/// Type reçu, tel que le nomme zod (`getParsedType`). +fn parsed_type(value: Option<&Value>) -> &'static str { + match value { + None => "undefined", + Some(Value::Null) => "null", + Some(Value::Bool(_)) => "boolean", + Some(Value::Number(_)) => "number", + Some(Value::String(_)) => "string", + Some(Value::Array(_)) => "array", + Some(Value::Object(_)) => "object", + } +} + +fn invalid_type(expected: &str, value: Option<&Value>) -> Issue { + match value { + None => "Required".into(), + Some(_) => format!("Expected {expected}, received {}", parsed_type(value)), + } +} + +/// `String.prototype.trim()` : espaces Unicode et BOM. +pub fn js_trim(text: &str) -> &str { + text.trim_matches(|c: char| (c.is_whitespace() && c != '\u{85}') || c == '\u{FEFF}') +} + +/// Longueur JavaScript (unités UTF-16). +fn js_len(text: &str) -> usize { + text.encode_utf16().count() +} + +fn join_values(options: &[&str]) -> String { + options + .iter() + .map(|o| format!("'{o}'")) + .collect::>() + .join(" | ") +} + +/// `z.string().trim().optional().transform(v => v || undefined)`. +fn optional_text(obj: &Map, key: &str) -> Result, Issue> { + match obj.get(key) { + None => Ok(None), + Some(Value::String(s)) => { + let trimmed = js_trim(s); + Ok((!trimmed.is_empty()).then(|| trimmed.to_string())) + } + other => Err(invalid_type("string", other)), + } +} + +/// `z.string().optional()` (sans trim). +fn optional_string(obj: &Map, key: &str) -> Result, Issue> { + match obj.get(key) { + None => Ok(None), + Some(Value::String(s)) => Ok(Some(s.clone())), + other => Err(invalid_type("string", other)), + } +} + +/// `z.boolean().default(default)`. +fn bool_or(obj: &Map, key: &str, default: bool) -> Result { + match obj.get(key) { + None => Ok(default), + Some(Value::Bool(b)) => Ok(*b), + other => Err(invalid_type("boolean", other)), + } +} + +fn enum_value(value: Option<&Value>, options: &[&str]) -> Result { + match value { + Some(Value::String(s)) if options.contains(&s.as_str()) => Ok(s.clone()), + Some(Value::String(s)) => Err(format!( + "Invalid enum value. Expected {}, received '{s}'", + join_values(options) + )), + other => Err(invalid_type(&join_values(options), other)), + } +} + +/// `z.enum(options).optional()`. +fn optional_enum( + obj: &Map, + key: &str, + options: &[&str], +) -> Result, Issue> { + match obj.get(key) { + None => Ok(None), + value => enum_value(value, options).map(Some), + } +} + +fn number(value: Option<&Value>) -> Result { + match value { + Some(Value::Number(n)) => n.as_f64().ok_or_else(|| invalid_type("number", value)), + other => Err(invalid_type("number", other)), + } +} + +fn check_min(n: f64, min: f64, label: &str) -> Result<(), Issue> { + if n < min { + return Err(format!("Number must be greater than or equal to {label}")); + } + Ok(()) +} + +fn check_max(n: f64, max: f64, label: &str) -> Result<(), Issue> { + if n > max { + return Err(format!("Number must be less than or equal to {label}")); + } + Ok(()) +} + +fn as_object(value: &Value) -> Result<&Map, Issue> { + match value { + Value::Object(map) => Ok(map), + other => Err(invalid_type("object", Some(other))), + } +} + +/// `srtCueSchema` : bornes, texte de 1 à 500 caractères, fin après le début. +fn srt_cue(value: &Value) -> Result { + let obj = as_object(value)?; + let start = number(obj.get("start"))?; + check_min(start, 0.0, "0")?; + let end = number(obj.get("end"))?; + check_min(end, 0.0, "0")?; + let text = match obj.get("text") { + Some(Value::String(s)) => js_trim(s).to_string(), + other => return Err(invalid_type("string", other)), + }; + if js_len(&text) < 1 { + return Err("String must contain at least 1 character(s)".into()); + } + if js_len(&text) > 500 { + return Err("String must contain at most 500 character(s)".into()); + } + if end <= start { + return Err("Sous-titre de durée nulle".into()); + } + Ok(SrtCue { start, end, text }) +} + +/// `srtCuesSchema.optional()`. +fn srt_cues(obj: &Map, key: &str) -> Result>, Issue> { + let items = match obj.get(key) { + None => return Ok(None), + Some(Value::Array(items)) => items, + other => return Err(invalid_type("array", other)), + }; + if items.is_empty() { + return Err("Fichier SRT vide".into()); + } + if items.len() > MAX_SRT_CUES { + return Err(format!( + "Fichier SRT trop long ({MAX_SRT_CUES} sous-titres au plus)" + )); + } + items + .iter() + .map(srt_cue) + .collect::, _>>() + .map(Some) +} + +/// Valide les paramètres d'un Reel vidéo (`videoReelParamsSchema`). +/// Les clés inconnues sont ignorées, les valeurs par défaut appliquées. +pub fn parse_video_reel_params(value: &Value) -> Result { + let obj = as_object(value)?; + + let video_media_id = match obj.get("videoMediaId") { + None => return Err("Vidéo requise".into()), + Some(Value::String(s)) if s.is_empty() => return Err("Vidéo requise".into()), + Some(Value::String(s)) => s.clone(), + other => return Err(invalid_type("string", other)), + }; + + let page_ids = match obj.get("pageIds") { + None => return Err("Au moins une page requise".into()), + Some(Value::Array(items)) => { + if items.is_empty() { + return Err("Au moins une page requise".into()); + } + let mut ids = Vec::with_capacity(items.len()); + for item in items { + match item { + Value::String(s) if s.is_empty() => { + return Err("String must contain at least 1 character(s)".into()) + } + Value::String(s) => ids.push(s.clone()), + other => return Err(invalid_type("string", Some(other))), + } + } + ids + } + other => return Err(invalid_type("array", other)), + }; + + let music_track_id = optional_text(obj, "musicTrackId")?; + let music_url = optional_text(obj, "musicUrl")?; + let overlay_text = optional_text(obj, "overlayText")?; + let srt_cues = srt_cues(obj, "srtCues")?; + let description = optional_text(obj, "description")?; + let tts_enabled = bool_or(obj, "ttsEnabled", false)?; + let tts_voice = optional_text(obj, "ttsVoice")?; + let tts_engine = optional_enum(obj, "ttsEngine", &TTS_ENGINES)?; + let tts_style = optional_enum(obj, "ttsStyle", &TTS_STYLES)?; + let scheduled_for = optional_text(obj, "scheduledFor")?; + let also_story = bool_or(obj, "alsoStory", false)?; + let word_duration = match obj.get("wordDuration") { + None => None, + value => Some(number(value)?), + }; + + let font_size = match obj.get("fontSize") { + None => 64, + value => { + let n = number(value)?; + if n.fract() != 0.0 || !n.is_finite() { + return Err("Expected integer, received float".into()); + } + check_min(n, 16.0, "16")?; + check_max(n, 200.0, "200")?; + n as i64 + } + }; + + let music_volume = match obj.get("musicVolume") { + None => 0.25, + value => { + let n = number(value)?; + check_min(n, 0.0, "0")?; + check_max(n, 2.0, "2")?; + n + } + }; + + let draw_text = bool_or(obj, "drawText", true)?; + let caption_style = match obj.get("captionStyle") { + None => DEFAULT_CAPTION_STYLE.to_string(), + value => enum_value(value, &CAPTION_STYLES)?, + }; + let stabilize = bool_or(obj, "stabilize", false)?; + let show_logo = bool_or(obj, "showLogo", true)?; + let enable_ending_effect = bool_or(obj, "enableEndingEffect", true)?; + let store_name = optional_string(obj, "storeName")?; + + Ok(VideoReelParams { + video_media_id, + page_ids, + music_track_id, + music_url, + overlay_text, + srt_cues, + description, + tts_enabled, + tts_voice, + tts_engine, + tts_style, + scheduled_for, + also_story, + word_duration, + font_size, + music_volume, + draw_text, + caption_style, + stabilize, + show_logo, + enable_ending_effect, + store_name, + }) +} + +/// Valide une demande d'aperçu de la voix (`ttsPreviewSchema`). +pub fn parse_tts_preview(value: &Value) -> Result { + let obj = as_object(value)?; + let text = match obj.get("text") { + None => return Err("Texte requis".into()), + Some(Value::String(s)) => js_trim(s).to_string(), + other => return Err(invalid_type("string", other)), + }; + if js_len(&text) < 1 { + return Err("Texte requis".into()); + } + if js_len(&text) > 2000 { + return Err("String must contain at most 2000 character(s)".into()); + } + Ok(TtsPreviewParams { + text, + tts_voice: optional_text(obj, "ttsVoice")?, + tts_engine: optional_enum(obj, "ttsEngine", &TTS_ENGINES)?, + tts_style: optional_enum(obj, "ttsStyle", &TTS_STYLES)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn base() -> Value { + json!({ "videoMediaId": "v", "pageIds": ["p"] }) + } + + fn with(extra: Value) -> Value { + let mut value = base(); + for (k, v) in extra.as_object().unwrap() { + value[k] = v.clone(); + } + value + } + + #[test] + fn exige_une_video_et_une_page() { + assert_eq!( + parse_video_reel_params(&json!({ "pageIds": [] })).unwrap_err(), + "Vidéo requise" + ); + assert_eq!( + parse_video_reel_params(&json!({ "videoMediaId": "" })).unwrap_err(), + "Vidéo requise" + ); + assert_eq!( + parse_video_reel_params(&json!({ "videoMediaId": "v", "pageIds": [] })).unwrap_err(), + "Au moins une page requise" + ); + assert_eq!( + parse_video_reel_params(&json!({ "videoMediaId": "v" })).unwrap_err(), + "Au moins une page requise" + ); + assert_eq!( + parse_video_reel_params(&json!({ "videoMediaId": 3, "pageIds": ["p"] })).unwrap_err(), + "Expected string, received number" + ); + assert_eq!( + parse_video_reel_params(&json!([])).unwrap_err(), + "Expected object, received array" + ); + } + + #[test] + fn applique_les_valeurs_par_defaut() { + let params = parse_video_reel_params(&base()).unwrap(); + assert_eq!(params.caption_style, "bold"); + assert!(!params.stabilize); + assert!(params.draw_text); + assert_eq!(params.music_volume, 0.25); + assert!(params.show_logo); + assert!(!params.also_story); + assert!(!params.tts_enabled); + assert!(params.enable_ending_effect); + assert_eq!(params.font_size, 64); + } + + #[test] + fn json_stocke_en_camel_case_sans_champs_vides() { + let params = parse_video_reel_params(&with(json!({ + "overlayText": " Bonjour ", + "description": " ", + "inconnu": 1, + }))) + .unwrap(); + let stored = serde_json::to_value(¶ms).unwrap(); + assert_eq!(stored["overlayText"], "Bonjour"); + assert!(stored.get("description").is_none()); + assert!(stored.get("inconnu").is_none()); + assert_eq!(stored["captionStyle"], "bold"); + assert_eq!(stored["enableEndingEffect"], true); + // Relu par le worker : identique + assert_eq!(parse_video_reel_params(&stored).unwrap(), params); + } + + #[test] + fn accepte_les_anciens_parametres() { + assert!(parse_video_reel_params(&with(json!({ "wordDuration": 0.6 }))).is_ok()); + } + + #[test] + fn refuse_un_moteur_de_voix_inconnu() { + assert_eq!( + parse_video_reel_params(&with(json!({ "ttsEngine": "autre" }))).unwrap_err(), + "Invalid enum value. Expected 'qwen' | 'gemini' | 'edge', received 'autre'" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "ttsStyle": null }))).unwrap_err(), + "Expected 'neutral' | 'dynamic' | 'warm' | 'calm' | 'promo', received null" + ); + } + + #[test] + fn bornes_numeriques() { + assert_eq!( + parse_video_reel_params(&with(json!({ "fontSize": 10 }))).unwrap_err(), + "Number must be greater than or equal to 16" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "fontSize": 300 }))).unwrap_err(), + "Number must be less than or equal to 200" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "fontSize": 20.5 }))).unwrap_err(), + "Expected integer, received float" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "fontSize": 72.0 }))) + .unwrap() + .font_size, + 72 + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "musicVolume": 3 }))).unwrap_err(), + "Number must be less than or equal to 2" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "musicVolume": -1 }))).unwrap_err(), + "Number must be greater than or equal to 0" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "musicTrackId": null }))).unwrap_err(), + "Expected string, received null" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "stabilize": "oui" }))).unwrap_err(), + "Expected boolean, received string" + ); + } + + #[test] + fn sous_titres_srt() { + let ok = parse_video_reel_params(&with( + json!({ "srtCues": [{ "start": 0, "end": 1, "text": " a " }] }), + )) + .unwrap(); + assert_eq!(ok.srt_cues.unwrap()[0].text, "a"); + assert_eq!( + parse_video_reel_params(&with( + json!({ "srtCues": [{ "start": 1, "end": 1, "text": "a" }] }) + )) + .unwrap_err(), + "Sous-titre de durée nulle" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "srtCues": [] }))).unwrap_err(), + "Fichier SRT vide" + ); + let many: Vec = (0..301) + .map(|i| json!({ "start": i, "end": i + 1, "text": "x" })) + .collect(); + assert_eq!( + parse_video_reel_params(&with(json!({ "srtCues": many }))).unwrap_err(), + "Fichier SRT trop long (300 sous-titres au plus)" + ); + assert_eq!( + parse_video_reel_params(&with(json!({ "srtCues": [{ "end": 1, "text": "a" }] }))) + .unwrap_err(), + "Required" + ); + } + + #[test] + fn texte_du_srt() { + let cues = vec![ + SrtCue { + start: 1.0, + end: 3.5, + text: "Bonjour à tous !".into(), + }, + SrtCue { + start: 4.0, + end: 6.25, + text: "Découvrez nos nouveautés en magasin".into(), + }, + ]; + assert_eq!( + srt_text(&cues), + "Bonjour à tous ! Découvrez nos nouveautés en magasin" + ); + } + + #[test] + fn apercu_de_la_voix() { + assert_eq!( + parse_tts_preview(&json!({ "text": " " })).unwrap_err(), + "Texte requis" + ); + assert_eq!(parse_tts_preview(&json!({})).unwrap_err(), "Texte requis"); + assert_eq!( + parse_tts_preview(&json!({ "text": "x".repeat(2001) })).unwrap_err(), + "String must contain at most 2000 character(s)" + ); + let ok = + parse_tts_preview(&json!({ "text": " Salut ", "ttsEngine": "edge", "ttsVoice": "" })) + .unwrap(); + assert_eq!(ok.text, "Salut"); + assert_eq!(ok.tts_engine.as_deref(), Some("edge")); + assert_eq!(ok.tts_voice, None); + } +} diff --git a/server/src/services/reels/pipeline.rs b/server/src/services/reels/pipeline.rs new file mode 100644 index 0000000..53d3680 --- /dev/null +++ b/server/src/services/reels/pipeline.rs @@ -0,0 +1,256 @@ +//! Rendu d'un Reel à partir d'une vidéo, puis médiathèque et publication. +//! Exécuté par la file `reel_jobs` (voir `queue.rs`). +//! +//! Deux moteurs : +//! - « remotion » (défaut) : le service Python prépare l'image (recadrage, HDR, +//! stabilisation) et la piste son finale, le service Remotion compose les +//! sous-titres animés, le logo et l'effet de fin — le même rendu que l'aperçu ; +//! - « ffmpeg » : tout est fait par le service Python (sous-titres ASS), +//! plus rapide, conservé en secours (`REEL_RENDERER=ffmpeg`). + +use std::time::Instant; + +use anyhow::{anyhow, bail}; +use bytes::Bytes; +use serde_json::{json, Map, Value}; + +use super::assets::{ + resolve_gemini_api_key, resolve_logo_path, resolve_music_url, resolve_qwen_target, +}; +use super::params::{parse_video_reel_params, VideoReelParams}; +use super::publish::{ + describe_publish_failure, publish_reel_to_pages, store_rendered_video, PublishOptions, +}; +use super::queue::JobContext; +use super::remotion::{render_composition, temp_file_url, to_data_url}; +use crate::db::MediaType; +use crate::services::ffmpeg::{self, ReelRenderOptions}; +use crate::services::media_store; +use crate::state::AppState; + +/// Moteur demandé par la configuration (`REEL_RENDERER`). +pub fn configured_renderer(state: &AppState) -> &'static str { + if state.config.reel_renderer == "ffmpeg" { + "ffmpeg" + } else { + "remotion" + } +} + +/// Moteur réellement utilisé : Remotion exige l'adresse de son service. +pub fn effective_renderer(state: &AppState) -> &'static str { + match configured_renderer(state) { + "remotion" if state.config.remotion_url.is_some() => "remotion", + _ => "ffmpeg", + } +} + +pub async fn run_video_reel_job(ctx: &JobContext) -> anyhow::Result { + let state = &ctx.state; + let job = &ctx.job; + let post_id = job + .post_id + .as_deref() + .ok_or_else(|| anyhow!("Job vidéo sans post associé"))?; + let params = parse_video_reel_params(&job.params).map_err(|issue| anyhow!(issue))?; + + ctx.progress(5, "prepare").await?; + let media = state.db.get_media_by_id(¶ms.video_media_id).await?; + let Some(media) = media.filter(|m| m.media_type == MediaType::Video) else { + bail!("Vidéo source introuvable ou invalide"); + }; + + let engine = params.tts_engine.as_deref(); + let (music_url, logo_path, gemini_api_key, qwen) = tokio::join!( + resolve_music_url( + state, + params.music_track_id.as_deref(), + params.music_url.as_deref() + ), + resolve_logo_path(state), + resolve_gemini_api_key(state, engine), + resolve_qwen_target(state, engine), + ); + + let options = ReelRenderOptions { + text: if params.srt_cues.is_some() { + None + } else { + params.overlay_text.clone() + }, + srt_cues: params.srt_cues.clone(), + music_url, + tts_enabled: Some(params.tts_enabled), + tts_voice: params.tts_voice.clone(), + tts_engine: params.tts_engine.clone(), + tts_style: params.tts_style.clone(), + gemini_api_key: gemini_api_key?, + qwen: qwen?, + font_size: Some(params.font_size), + music_volume: Some(params.music_volume), + draw_text: Some(params.draw_text), + stabilize: Some(params.stabilize), + watermark_url: None, + store_name: params.store_name.clone(), + enable_ending_effect: Some(params.enable_ending_effect), + show_logo: Some(params.show_logo), + }; + + let renderer = match (configured_renderer(state), &state.config.remotion_url) { + ("remotion", None) => { + tracing::warn!( + "⚠️ [Reels] REMOTION_RENDERER_URL non défini : rendu par le service FFmpeg à la place de Remotion" + ); + "ffmpeg" + } + (renderer, _) => renderer, + }; + + let video_url = media_store::resolve_internal_url(state, &media.original_url); + let started = Instant::now(); + let video = match (renderer, state.config.remotion_url.as_deref()) { + ("remotion", Some(base_url)) => { + render_with_remotion( + ctx, + base_url, + &video_url, + &options, + ¶ms, + logo_path.as_deref(), + ) + .await? + } + _ => render_with_ffmpeg(ctx, &video_url, options, logo_path.as_deref()).await?, + }; + tracing::info!( + "⏱️ [Reels] Rendu {renderer} : {:.1} s", + started.elapsed().as_secs_f64() + ); + + ctx.progress(88, "store").await?; + let file_name = format!("reel-{}.mp4", chrono::Utc::now().timestamp_millis()); + let processed = store_rendered_video(state, &job.user_id, &video, &file_name).await?; + state + .db + .update_post_media(post_id, std::slice::from_ref(&processed.id)) + .await?; + + ctx.progress(93, "publish").await?; + let description = params + .description + .clone() + .or_else(|| params.overlay_text.clone()) + .unwrap_or_default(); + let results = publish_reel_to_pages( + state, + PublishOptions { + post_id, + page_ids: ¶ms.page_ids, + video, + description: &description, + scheduled_for: params.scheduled_for.as_deref(), + also_story: params.also_story, + }, + ) + .await?; + + if !results.iter().any(|r| r.success) { + bail!(describe_publish_failure(&results)); + } + + Ok(json!({ "mediaId": processed.id, "renderer": renderer, "results": results })) +} + +async fn render_with_ffmpeg( + ctx: &JobContext, + video_url: &str, + mut options: ReelRenderOptions, + logo_path: Option<&str>, +) -> anyhow::Result { + ctx.progress(15, "render").await?; + // Le logo sert au coin de la vidéo et/ou à l'effet de fin : inutile de l'envoyer si aucun des deux + let logo_used = options.show_logo != Some(false) || options.enable_ending_effect != Some(false); + options.watermark_url = logo_path + .filter(|_| logo_used) + .map(|path| media_store::resolve_internal_url(&ctx.state, path)); + let rendered = ffmpeg::render_reel(&ctx.state, video_url, &options).await?; + Ok(rendered.video) +} + +async fn render_with_remotion( + ctx: &JobContext, + base_url: &str, + video_url: &str, + options: &ReelRenderOptions, + params: &VideoReelParams, + logo_path: Option<&str>, +) -> anyhow::Result { + let state = &ctx.state; + let workdir = state + .config + .temp_upload_dir + .join(format!("reel-{}", ctx.job.id)); + let result = async { + // 1. Image recadrée à la durée finale, piste son finale, mots minutés + ctx.progress(10, "voice").await?; + let prepared = + ffmpeg::prepare_reel(state, video_url, options, logo_path.is_some(), &workdir).await?; + + // 2. Composition : sous-titres animés, logo, effet de fin + ctx.progress(40, "render").await?; + let mut props = Map::new(); + props.insert( + "videoUrl".into(), + temp_file_url(state, &prepared.video_path).into(), + ); + props.insert("videoDuration".into(), prepared.total_duration.into()); + props.insert("totalDuration".into(), prepared.total_duration.into()); + props.insert("words".into(), serde_json::to_value(&prepared.words)?); + props.insert("captionStyle".into(), params.caption_style.clone().into()); + if let Some(logo) = logo_path { + props.insert("logoUrl".into(), to_data_url(state, logo).await?.into()); + } + props.insert("showWatermark".into(), params.show_logo.into()); + if params.enable_ending_effect { + if let Some(name) = ¶ms.store_name { + props.insert("storeName".into(), name.clone().into()); + } + } + props.insert("logoStart".into(), prepared.logo_start.into()); + props.insert("fadeStart".into(), prepared.fade_start.into()); + props.insert("fadeDuration".into(), prepared.fade_duration.into()); + if let Some(audio) = &prepared.audio_path { + props.insert("mixedAudioUrl".into(), temp_file_url(state, audio).into()); + } + + let output = workdir.join("reel.mp4"); + let mut last_reported = 40; + render_composition( + state, + base_url, + "ReelVideo", + Value::Object(props), + &output, + |ratio| { + let pct = 40 + (ratio * 45.0).floor() as i32; + let report = pct >= last_reported + 5; + if report { + last_reported = pct; + } + async move { + if report { + // Non bloquant : un échec d'écriture n'interrompt pas le rendu + let _ = ctx.progress(pct, "render").await; + } + } + }, + ) + .await?; + Ok(Bytes::from(tokio::fs::read(&output).await?)) + } + .await; + + // Dossier toujours nettoyé, rendu réussi ou non + let _ = tokio::fs::remove_dir_all(&workdir).await; + result +} diff --git a/server/src/services/reels/publish.rs b/server/src/services/reels/publish.rs new file mode 100644 index 0000000..61fe8d8 --- /dev/null +++ b/server/src/services/reels/publish.rs @@ -0,0 +1,350 @@ +//! Enregistrement et publication d'un Reel rendu. +//! +//! La vidéo est toujours envoyée en binaire : les fichiers vivent sous +//! /uploads, une URL relative que Facebook ne peut pas télécharger. + +use bytes::Bytes; +use serde::Serialize; + +use crate::db::{ + Media, MediaType, NewMedia, NewScheduledPost, Patch, Platform, PostStatus, PostType, SocialPage, +}; +use crate::services::media_store::{self, MediaSource}; +use crate::services::thumbnail::{self, ThumbnailSource}; +use crate::services::{facebook, tiktok}; +use crate::state::AppState; +use crate::time::Ts; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PagePublishResult { + pub page_id: String, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reel_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Échec de la story associée : n'empêche pas le Reel d'être publié. + #[serde(skip_serializing_if = "Option::is_none")] + pub story_error: Option, +} + +impl PagePublishResult { + fn failed(page_id: &str, error: impl Into) -> Self { + Self { + page_id: page_id.to_string(), + success: false, + reel_id: None, + error: Some(error.into()), + story_error: None, + } + } + + fn ok(page_id: &str, reel_id: impl Into, story_error: Option) -> Self { + Self { + page_id: page_id.to_string(), + success: true, + reel_id: Some(reel_id.into()), + error: None, + story_error, + } + } +} + +/// Range la vidéo rendue dans la médiathèque, avec sa vignette. +pub async fn store_rendered_video( + state: &AppState, + user_id: &str, + video: &[u8], + file_name: &str, +) -> anyhow::Result { + let uploaded = media_store::upload_media(state, MediaSource::Bytes(video), file_name).await?; + // Vignette extraite maintenant : la vidéo sera supprimée du disque après + // publication, mais l'historique doit rester illustré. + let thumbnail_url = + thumbnail::create_video_thumbnail(state, ThumbnailSource::Bytes(video)).await; + + Ok(state + .db + .create_media(NewMedia { + user_id: user_id.to_string(), + media_type: Some(MediaType::Video), + cloudinary_public_id: Some(uploaded.public_id), + original_url: uploaded.original_url, + facebook_landscape_url: None, + facebook_feed_url: uploaded.facebook_feed_url, + instagram_feed_url: uploaded.instagram_feed_url, + instagram_story_url: uploaded.instagram_story_url, + thumbnail_url, + file_name: file_name.to_string(), + file_size: i32::try_from(video.len()).unwrap_or(i32::MAX), + }) + .await?) +} + +pub struct PublishOptions<'a> { + pub post_id: &'a str, + pub page_ids: &'a [String], + pub video: Bytes, + pub description: &'a str, + pub scheduled_for: Option<&'a str>, + pub also_story: bool, +} + +/// `new Date(value)` ; une date invalide fait échouer l'écriture comme avec Drizzle. +fn parse_date(value: &str) -> anyhow::Result { + Ts::parse(value).ok_or_else(|| anyhow::anyhow!("Invalid time value")) +} + +/// Publie (ou planifie) la vidéo sur chaque page, crée les entrées +/// `scheduled_posts` et met à jour le statut du post. +/// +/// Avec `also_story`, chaque page Facebook reçoit aussi la vidéo en story +/// (TikTok n'a pas de stories). +pub async fn publish_reel_to_pages( + state: &AppState, + options: PublishOptions<'_>, +) -> anyhow::Result> { + let mut results = Vec::with_capacity(options.page_ids.len()); + + for page_id in options.page_ids { + match publish_to_page(state, &options, page_id).await { + Ok(result) => results.push(result), + Err(error) => { + tracing::error!( + "❌ [Reels] Publication sur la page {page_id} impossible : {error:#}" + ); + results.push(PagePublishResult::failed(page_id, error.to_string())); + } + } + } + + let succeeded = results.iter().filter(|r| r.success).count(); + let status = if options.scheduled_for.is_some() && succeeded > 0 { + PostStatus::Scheduled + } else if succeeded > 0 { + PostStatus::Published + } else { + PostStatus::Failed + }; + state + .db + .update_post(options.post_id, Patch::new().set("status", status)) + .await?; + + Ok(results) +} + +async fn publish_to_page( + state: &AppState, + options: &PublishOptions<'_>, + page_id: &str, +) -> anyhow::Result { + let Some(page) = state.db.get_social_page(page_id).await? else { + return Ok(PagePublishResult::failed(page_id, "Page non trouvée")); + }; + if page.platform != Platform::Facebook && page.platform != Platform::Tiktok { + return Ok(PagePublishResult::failed( + page_id, + format!( + "Plateforme non supportée pour les reels : {}", + page.platform + ), + )); + } + + let scheduled_at = match options.scheduled_for { + Some(date) => parse_date(date)?, + None => Ts::now(), + }; + let scheduled_post = state + .db + .create_scheduled_post(NewScheduledPost::new( + options.post_id, + &page.id, + PostType::Reel, + scheduled_at, + )) + .await?; + let with_story = options.also_story && page.platform == Platform::Facebook; + + if let Some(date) = options.scheduled_for { + // Le planificateur publiera à l'heure dite + if with_story { + state + .db + .create_scheduled_post(NewScheduledPost::new( + options.post_id, + &page.id, + PostType::Story, + parse_date(date)?, + )) + .await?; + } + return Ok(PagePublishResult::ok(page_id, "scheduled", None)); + } + + tracing::info!( + "🚀 [Reels] Publication sur {} {}...", + page.platform, + page.page_name + ); + if page.platform == Platform::Facebook { + let reel_id = facebook::publish_video_from_buffer( + state, + &page, + options.video.clone(), + options.description, + ) + .await?; + state + .db + .update_scheduled_post( + &scheduled_post.id, + Patch::new() + .set("published_at", Ts::now()) + .set("external_post_id", reel_id.clone()), + ) + .await?; + let story_error = if with_story { + publish_story(state, options.post_id, &page, options.video.clone()).await + } else { + None + }; + Ok(PagePublishResult::ok(page_id, reel_id, story_error)) + } else { + // TikTok finalise la publication de son côté : l'envoi est marqué comme + // effectué pour ne pas republier, et le suivi de statut renseignera + // l'identifiant définitif du post. + let publish_id = tiktok::publish_video_from_buffer( + state, + &page, + options.video.clone(), + options.description, + ) + .await?; + state + .db + .update_scheduled_post( + &scheduled_post.id, + Patch::new() + .set("published_at", Ts::now()) + .set("publish_id", publish_id.clone()) + .set("publish_status", "PROCESSING_UPLOAD"), + ) + .await?; + Ok(PagePublishResult::ok(page_id, publish_id, None)) + } +} + +/// Publie la story d'une page juste après son Reel : un échec est consigné sur +/// son entrée planifiée (visible dans l'historique) et renvoyé, sans faire +/// échouer le Reel. +async fn publish_story( + state: &AppState, + post_id: &str, + page: &SocialPage, + video: Bytes, +) -> Option { + // Créée seulement maintenant : en attente pendant l'envoi du Reel, le + // planificateur aurait pu la publier une seconde fois. + let mut scheduled_story_id: Option = None; + let result: anyhow::Result<()> = async { + let scheduled = state + .db + .create_scheduled_post(NewScheduledPost::new( + post_id, + &page.id, + PostType::Story, + Ts::now(), + )) + .await?; + scheduled_story_id = Some(scheduled.id.clone()); + let story_id = facebook::publish_video_story_from_buffer(state, page, video).await?; + state + .db + .update_scheduled_post( + &scheduled.id, + Patch::new() + .set("published_at", Ts::now()) + .set("external_post_id", story_id), + ) + .await?; + Ok(()) + } + .await; + + match result { + Ok(()) => None, + Err(error) => { + let message = error.to_string(); + tracing::error!( + "❌ [Reels] Story sur {} impossible : {error:#}", + page.page_name + ); + if let Some(id) = scheduled_story_id { + // Marquée comme traitée pour que le planificateur ne la republie pas en boucle + if let Err(e) = state + .db + .update_scheduled_post( + &id, + Patch::new() + .set("published_at", Ts::now()) + .set("error", message.clone()), + ) + .await + { + tracing::error!("⚠️ [Reels] Échec de la story non enregistré : {e}"); + } + } + Some(message) + } + } +} + +/// Message d'erreur lisible lorsque toutes les publications ont échoué. +pub fn describe_publish_failure(results: &[PagePublishResult]) -> String { + let details: Vec<&str> = results + .iter() + .filter_map(|r| r.error.as_deref()) + .filter(|e| !e.is_empty()) + .collect(); + if details.is_empty() { + "Publication impossible sur toutes les pages".into() + } else { + format!( + "Publication impossible sur toutes les pages : {}", + details.join(" ; ") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn message_d_echec_global() { + let results = vec![ + PagePublishResult::failed("a", "Page non trouvée"), + PagePublishResult::failed("b", "Jeton expiré"), + ]; + assert_eq!( + describe_publish_failure(&results), + "Publication impossible sur toutes les pages : Page non trouvée ; Jeton expiré" + ); + assert_eq!( + describe_publish_failure(&[]), + "Publication impossible sur toutes les pages" + ); + } + + #[test] + fn resultat_serialise_sans_champs_vides() { + let value = serde_json::to_value(PagePublishResult::ok("p", "scheduled", None)).unwrap(); + assert_eq!( + value, + serde_json::json!({ "pageId": "p", "success": true, "reelId": "scheduled" }) + ); + } +} diff --git a/server/src/services/reels/queue.rs b/server/src/services/reels/queue.rs new file mode 100644 index 0000000..5081191 --- /dev/null +++ b/server/src/services/reels/queue.rs @@ -0,0 +1,391 @@ +//! File d'attente persistante des rendus de Reels. +//! +//! Chaque job est une ligne de `reel_jobs` : +//! - le worker la réserve atomiquement (`FOR UPDATE SKIP LOCKED`), ce qui +//! exclut tout double traitement, même avec plusieurs instances ; +//! - un battement de cœur est écrit pendant le rendu ; au démarrage, les jobs +//! dont le cœur ne bat plus sont remis en file (ou échouent après +//! `MAX_ATTEMPTS` tentatives). + +use std::time::Duration; + +use serde_json::Value; + +use crate::db::ReelJob; +use crate::state::AppState; +use crate::time::Ts; + +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15); +/// Au-delà, un job « processing » est considéré comme orphelin. +const STALE_AFTER_MS: i64 = 2 * 60_000; +const POLL_INTERVAL: Duration = Duration::from_secs(10); +const MAX_ATTEMPTS: i32 = 2; + +/// Outils fournis à un traitement pour rendre compte de son avancement. +pub struct JobContext { + pub state: AppState, + pub job: ReelJob, +} + +impl JobContext { + pub async fn progress(&self, progress: i32, step: &str) -> anyhow::Result<()> { + update_job( + &self.state, + &self.job, + JobPatch { + progress: Some(progress), + step: Some(step.to_string()), + ..Default::default() + }, + ) + .await + } +} + +/// Champs modifiés d'un job (les `None` ne sont pas écrits). +#[derive(Debug, Default)] +struct JobPatch { + status: Option<&'static str>, + progress: Option, + step: Option, + result: Option, + error: Option, +} + +/// Ajoute un job à la file et réveille le worker. +pub async fn enqueue_reel_job( + state: &AppState, + kind: &str, + user_id: &str, + post_id: Option<&str>, + params: Value, +) -> Result { + let job: ReelJob = sqlx::query_as( + "INSERT INTO reel_jobs (kind, user_id, post_id, params) VALUES ($1, $2, $3, $4) RETURNING *", + ) + .bind(kind) + .bind(user_id) + .bind(post_id) + .bind(params) + .fetch_one(&state.db.pool) + .await?; + + if let Some(post_id) = &job.post_id { + sync_post(state, post_id, "pending", Some(0), None).await?; + } + + state.reel_queue.notify_one(); + Ok(job) +} + +pub async fn get_reel_job(state: &AppState, id: &str) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM reel_jobs WHERE id = $1") + .bind(id) + .fetch_optional(&state.db.pool) + .await +} + +/// Nombre de jobs qui passeront avant un nouveau job (pour informer l'utilisateur). +pub async fn count_active_reel_jobs(state: &AppState) -> Result { + sqlx::query_scalar( + "SELECT count(*)::bigint FROM reel_jobs WHERE status IN ('pending', 'processing')", + ) + .fetch_one(&state.db.pool) + .await +} + +/// Démarre le worker : récupère les jobs orphelins puis traite la file. +pub fn start(state: AppState) { + tokio::spawn(async move { + if let Err(error) = recover(&state).await { + tracing::error!("❌ [ReelQueue] Récupération des jobs orphelins impossible : {error}"); + } + tracing::info!("🎬 [ReelQueue] Worker démarré (traitements : video)"); + + loop { + if let Err(error) = drain(&state).await { + tracing::error!("❌ [ReelQueue] Erreur de la boucle : {error}"); + } + // Réveil à l'ajout d'un job, ou toutes les 10 s + tokio::select! { + _ = state.reel_queue.notified() => {} + _ = tokio::time::sleep(POLL_INTERVAL) => {} + } + } + }); +} + +async fn recover(state: &AppState) -> anyhow::Result<()> { + recover_stale_jobs(state).await?; + fail_legacy_stuck_posts(state).await?; + Ok(()) +} + +async fn drain(state: &AppState) -> anyhow::Result<()> { + while let Some(job) = claim_next_job(state).await? { + run_job(state, job).await; + } + Ok(()) +} + +async fn claim_next_job(state: &AppState) -> Result, sqlx::Error> { + let id: Option = sqlx::query_scalar( + r#"UPDATE reel_jobs + SET status = 'processing', + attempts = attempts + 1, + heartbeat_at = now(), + updated_at = now(), + error = NULL + WHERE id = ( + SELECT id FROM reel_jobs + WHERE status = 'pending' + ORDER BY created_at + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + RETURNING id"#, + ) + .fetch_optional(&state.db.pool) + .await?; + match id { + Some(id) => get_reel_job(state, &id).await, + None => Ok(None), + } +} + +/// Message lisible d'une panique du traitement. +fn panic_message(error: tokio::task::JoinError) -> String { + match error.try_into_panic() { + Ok(payload) => payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| "Erreur inattendue pendant le rendu".into()), + Err(error) => error.to_string(), + } +} + +async fn run_job(state: &AppState, job: ReelJob) { + tracing::info!( + "🎬 [ReelQueue] Job {} ({}), tentative {}", + job.id, + job.kind, + job.attempts + ); + + let heartbeat = { + let state = state.clone(); + let id = job.id.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL); + interval.tick().await; + loop { + interval.tick().await; + if let Err(error) = + sqlx::query("UPDATE reel_jobs SET heartbeat_at = $2 WHERE id = $1") + .bind(&id) + .bind(Ts::now()) + .execute(&state.db.pool) + .await + { + tracing::warn!("⚠️ [ReelQueue] Heartbeat {id} : {error}"); + } + } + }) + }; + + // Le traitement tourne dans sa propre tâche : une panique fait échouer le + // job sans arrêter le worker. + let ctx = JobContext { + state: state.clone(), + job: job.clone(), + }; + let outcome = tokio::spawn(async move { + if ctx.job.kind != "video" { + anyhow::bail!( + "Aucun traitement enregistré pour les jobs « {} »", + ctx.job.kind + ); + } + if let Some(post_id) = &ctx.job.post_id { + sync_post(&ctx.state, post_id, "processing", Some(0), None).await?; + } + super::pipeline::run_video_reel_job(&ctx).await + }) + .await; + + let outcome = match outcome { + Ok(result) => result.map_err(|e| e.to_string()), + Err(join_error) => Err(panic_message(join_error)), + }; + + match outcome { + Ok(result) => { + let patch = JobPatch { + status: Some("completed"), + progress: Some(100), + step: Some("done".into()), + result: Some(result), + error: None, + }; + match update_job(state, &job, patch).await { + Ok(()) => tracing::info!("✅ [ReelQueue] Job {} terminé", job.id), + Err(error) => tracing::error!( + "⚠️ [ReelQueue] Statut final non enregistré pour {} : {error}", + job.id + ), + } + } + Err(message) => { + tracing::error!("❌ [ReelQueue] Job {} en échec : {message}", job.id); + let patch = JobPatch { + status: Some("failed"), + error: Some(message), + ..Default::default() + }; + if let Err(error) = update_job(state, &job, patch).await { + tracing::error!( + "⚠️ [ReelQueue] Statut d'échec non enregistré pour {} : {error}", + job.id + ); + } + } + } + heartbeat.abort(); +} + +async fn update_job(state: &AppState, job: &ReelJob, patch: JobPatch) -> anyhow::Result<()> { + let now = Ts::now(); + let mut qb = sqlx::QueryBuilder::::new("UPDATE reel_jobs SET heartbeat_at = "); + qb.push_bind(now).push(", updated_at = ").push_bind(now); + if let Some(status) = patch.status { + qb.push(", status = ").push_bind(status); + } + if let Some(progress) = patch.progress { + qb.push(", progress = ").push_bind(progress); + } + if let Some(step) = &patch.step { + qb.push(", step = ").push_bind(step.clone()); + } + if let Some(result) = &patch.result { + qb.push(", result = ").push_bind(result.clone()); + } + if let Some(error) = &patch.error { + qb.push(", error = ").push_bind(error.clone()); + } + qb.push(" WHERE id = ").push_bind(job.id.clone()); + qb.build().execute(&state.db.pool).await?; + + if let Some(post_id) = &job.post_id { + let status = patch.status.unwrap_or("processing"); + let progress = if patch.status == Some("failed") { + Some(0) + } else { + patch.progress + }; + sync_post(state, post_id, status, progress, patch.error.as_deref()).await?; + } + Ok(()) +} + +/// Reflète l'état du job sur le post : l'interface (`/reels/ongoing`) lit les +/// colonnes `generation_*` du post. +async fn sync_post( + state: &AppState, + post_id: &str, + status: &str, + progress: Option, + error: Option<&str>, +) -> Result<(), sqlx::Error> { + let mut qb = sqlx::QueryBuilder::::new("UPDATE posts SET generation_status = "); + qb.push_bind(status.to_string()) + .push(", updated_at = ") + .push_bind(Ts::now()); + if let Some(progress) = progress { + qb.push(", generation_progress = ").push_bind(progress); + } + if let Some(error) = error { + qb.push(", generation_error = ") + .push_bind(error.to_string()); + } + if status == "failed" { + qb.push(", status = 'failed'::post_status"); + } + qb.push(" WHERE id = ").push_bind(post_id.to_string()); + qb.build().execute(&state.db.pool).await?; + Ok(()) +} + +/// Remet en file les jobs interrompus (serveur arrêté pendant un rendu), ou +/// les fait échouer s'ils ont déjà épuisé leurs tentatives : un job qui fait +/// tomber le serveur ne doit pas le faire tomber en boucle. +async fn recover_stale_jobs(state: &AppState) -> anyhow::Result<()> { + let stale_before = Ts::now().add(chrono::Duration::milliseconds(-STALE_AFTER_MS)); + let stale: Vec = sqlx::query_as( + "SELECT * FROM reel_jobs WHERE status = 'processing' AND (heartbeat_at IS NULL OR heartbeat_at < $1)", + ) + .bind(stale_before) + .fetch_all(&state.db.pool) + .await?; + + for job in stale { + if job.attempts >= MAX_ATTEMPTS { + tracing::warn!( + "⚠️ [ReelQueue] Job {} abandonné après {} tentatives", + job.id, + job.attempts + ); + update_job( + state, + &job, + JobPatch { + status: Some("failed"), + error: Some( + "Le rendu a été interrompu à plusieurs reprises (redémarrage du serveur)." + .into(), + ), + ..Default::default() + }, + ) + .await?; + } else { + tracing::warn!("♻️ [ReelQueue] Job {} interrompu, remis en file", job.id); + sqlx::query( + "UPDATE reel_jobs SET status = 'pending', step = NULL, progress = 0, updated_at = $2 WHERE id = $1", + ) + .bind(&job.id) + .bind(Ts::now()) + .execute(&state.db.pool) + .await?; + if let Some(post_id) = &job.post_id { + sync_post(state, post_id, "pending", Some(0), None).await?; + } + } + } + Ok(()) +} + +/// Posts laissés « processing »/« pending » par l'ancienne file (avant +/// reel_jobs) : aucun job ne peut les reprendre. On les marque en échec pour +/// que l'utilisateur relance. +async fn fail_legacy_stuck_posts(state: &AppState) -> Result<(), sqlx::Error> { + let result = sqlx::query( + r#"UPDATE posts + SET generation_status = 'failed', + generation_progress = 0, + generation_error = 'Génération interrompue, veuillez relancer le Reel.', + status = 'failed', + updated_at = now() + WHERE generation_status IN ('processing', 'pending') + AND NOT EXISTS (SELECT 1 FROM reel_jobs j WHERE j.post_id = posts.id)"#, + ) + .execute(&state.db.pool) + .await?; + if result.rows_affected() > 0 { + tracing::warn!( + "⚠️ [ReelQueue] {} Reel(s) de l'ancienne file marqués en échec", + result.rows_affected() + ); + } + Ok(()) +} diff --git a/server/src/services/reels/remotion.rs b/server/src/services/reels/remotion.rs new file mode 100644 index 0000000..044b3ff --- /dev/null +++ b/server/src/services/reels/remotion.rs @@ -0,0 +1,346 @@ +//! Rendu Remotion : ressources fournies aux compositions et client du service +//! de rendu (conteneur Node `remotion-service`, voir ce dossier). +//! +//! Le serveur Rust ne peut pas exécuter Remotion (React + Chromium) : il +//! confie la composition au service, qui lit la vidéo et le son préparés via +//! les URL `/uploads/temp/...` servies par l'application. + +use std::future::Future; +use std::path::{Component, Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, bail}; +use base64::Engine; +use futures::StreamExt; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::io::AsyncWriteExt; + +use crate::state::AppState; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60); +const POLL_INTERVAL: Duration = Duration::from_secs(1); +/// Garde-fou : un rendu bloqué ne doit pas immobiliser la file indéfiniment. +const MAX_RENDER_TIME: Duration = Duration::from_secs(60 * 60); +/// Erreurs réseau consécutives tolérées pendant le suivi (redémarrage bref, etc.). +const MAX_POLL_ERRORS: u32 = 10; + +/// Base des URL internes par lesquelles le service de rendu joint l'application. +fn internal_base(state: &AppState) -> String { + state + .config + .internal_app_url + .clone() + .unwrap_or_else(|| format!("http://localhost:{}", state.config.port)) +} + +/// URL servie (`/uploads/temp/...`) d'un fichier du dossier temporaire, +/// joignable par le service de rendu. +pub fn temp_file_url(state: &AppState, absolute_path: &Path) -> String { + let relative = absolute_path + .strip_prefix(&state.config.temp_upload_dir) + .unwrap_or(absolute_path) + .components() + .filter_map(|c| match c { + Component::Normal(part) => Some(part.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/"); + format!("{}/uploads/temp/{relative}", internal_base(state)) +} + +/// Normalisation lexicale (`path.resolve`) : résout `.` et `..` sans toucher au disque. +fn normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::ParentDir => { + out.pop(); + } + Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out +} + +fn absolute(path: &Path) -> PathBuf { + normalize(&std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())) +} + +fn mime_for(path: &Path) -> &'static str { + match path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("png") => "image/png", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("svg") => "image/svg+xml", + _ => "image/jpeg", + } +} + +/// Convertit un chemin local /uploads/... en data URL : les images sont +/// affichées par Chromium dans le service de rendu. Refuse tout chemin +/// sortant du dossier uploads. +pub async fn to_data_url(state: &AppState, relative_url: &str) -> anyhow::Result { + to_data_url_in( + &state.config.uploads_dir, + &state.config.temp_upload_dir, + relative_url, + ) + .await +} + +pub async fn to_data_url_in( + uploads_dir: &Path, + temp_dir: &Path, + relative_url: &str, +) -> anyhow::Result { + if !relative_url.starts_with("/uploads/") { + return Ok(relative_url.to_string()); + } + let uploads_root = absolute(uploads_dir); + let temp_root = absolute(temp_dir); + let file_path = match relative_url.strip_prefix("/uploads/temp/") { + Some(rest) => normalize(&temp_root.join(rest)), + None => normalize(&uploads_root.join(&relative_url["/uploads/".len()..])), + }; + let inside = |root: &Path| file_path.starts_with(root) && file_path != root; + if !inside(&uploads_root) && !inside(&temp_root) { + bail!("Chemin d'image refusé : {relative_url}"); + } + match tokio::fs::read(&file_path).await { + Ok(data) => Ok(format!( + "data:{};base64,{}", + mime_for(&file_path), + base64::engine::general_purpose::STANDARD.encode(data) + )), + Err(_) => { + tracing::warn!("⚠️ [Remotion] Image introuvable : {}", file_path.display()); + Ok(relative_url.to_string()) + } + } +} + +/// Clé du service de rendu : la même que celle du service FFmpeg. +async fn api_key(state: &AppState) -> String { + state + .ffmpeg + .read() + .await + .api_key + .clone() + .unwrap_or_default() +} + +/// Message d'erreur d'une réponse du service (`{ error }`), sinon le corps. +async fn response_error(response: reqwest::Response) -> String { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + let message = serde_json::from_str::(&text) + .ok() + .and_then(|v| v.get("error").and_then(Value::as_str).map(str::to_string)) + .unwrap_or(text); + format!( + "Service de rendu Remotion ({}) : {}", + status.as_u16(), + message.chars().take(2000).collect::() + ) +} + +#[derive(Debug, Deserialize)] +struct RenderStatus { + status: String, + #[serde(default)] + progress: f64, + #[serde(default)] + error: Option, +} + +/// Rend une composition dans le service Remotion et écrit le MP4 dans +/// `output`. `on_progress` reçoit l'avancement (0..1). Le job du service est +/// toujours supprimé à la fin, réussi ou non. +pub async fn render_composition( + state: &AppState, + base_url: &str, + composition_id: &str, + input_props: Value, + output: &Path, + mut on_progress: F, +) -> anyhow::Result<()> +where + F: FnMut(f64) -> Fut, + Fut: Future, +{ + let base = base_url.trim_end_matches('/'); + let key = api_key(state).await; + let unreachable = |e: reqwest::Error| anyhow!("Service de rendu Remotion injoignable : {e}"); + + let response = state + .http + .post(format!("{base}/renders")) + .header("X-API-Key", &key) + .timeout(REQUEST_TIMEOUT) + .json(&json!({ "compositionId": composition_id, "inputProps": input_props })) + .send() + .await + .map_err(unreachable)?; + if !response.status().is_success() { + bail!(response_error(response).await); + } + #[derive(Deserialize)] + struct Created { + id: String, + } + let created: Created = response.json().await.map_err(unreachable)?; + let render_url = format!("{base}/renders/{}", created.id); + + let started = Instant::now(); + let result = async { + let mut errors = 0; + loop { + if started.elapsed() > MAX_RENDER_TIME { + bail!( + "Rendu Remotion trop long (plus de {} min)", + MAX_RENDER_TIME.as_secs() / 60 + ); + } + tokio::time::sleep(POLL_INTERVAL).await; + let response = match state + .http + .get(&render_url) + .header("X-API-Key", &key) + .timeout(REQUEST_TIMEOUT) + .send() + .await + { + Ok(response) => response, + Err(error) => { + errors += 1; + if errors >= MAX_POLL_ERRORS { + return Err(unreachable(error)); + } + continue; + } + }; + if !response.status().is_success() { + bail!(response_error(response).await); + } + let status: RenderStatus = response.json().await.map_err(unreachable)?; + errors = 0; + match status.status.as_str() { + "done" => break, + "failed" => bail!(status + .error + .unwrap_or_else(|| "Rendu Remotion en échec".into())), + _ => on_progress(status.progress.clamp(0.0, 1.0)).await, + } + } + + // Téléchargement en flux du MP4 produit + let response = state + .http + .get(format!("{render_url}/file")) + .header("X-API-Key", &key) + .timeout(DOWNLOAD_TIMEOUT) + .send() + .await + .map_err(unreachable)?; + if !response.status().is_success() { + bail!(response_error(response).await); + } + let mut file = tokio::fs::File::create(output).await?; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + file.write_all(&chunk.map_err(unreachable)?).await?; + } + file.flush().await?; + tracing::info!( + "🎞️ [Remotion] {composition_id} rendu en {:.1} s", + started.elapsed().as_secs_f64() + ); + Ok(()) + } + .await; + + // Le fichier n'est plus utile au service (sinon purgé plus tard) + let _ = state + .http + .delete(&render_url) + .header("X-API-Key", &key) + .timeout(Duration::from_secs(5)) + .send() + .await; + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dirs() -> (PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!("sf-remotion-test-{}", std::process::id())); + (root.join("uploads"), root.join("uploads").join("temp")) + } + + #[tokio::test] + async fn refuse_un_chemin_qui_sort_du_dossier_uploads() { + let (uploads, temp) = dirs(); + let error = to_data_url_in(&uploads, &temp, "/uploads/../../etc/passwd") + .await + .unwrap_err(); + assert!(error.to_string().contains("refusé")); + let error = to_data_url_in(&uploads, &temp, "/uploads/temp/../../../etc/passwd") + .await + .unwrap_err(); + assert!(error.to_string().contains("refusé")); + } + + #[tokio::test] + async fn laisse_les_url_externes_telles_quelles() { + let (uploads, temp) = dirs(); + assert_eq!( + to_data_url_in(&uploads, &temp, "https://exemple.fr/logo.png") + .await + .unwrap(), + "https://exemple.fr/logo.png" + ); + } + + #[tokio::test] + async fn convertit_un_logo_en_data_url() { + let (uploads, temp) = dirs(); + let logos = uploads.join("logos"); + std::fs::create_dir_all(&logos).unwrap(); + std::fs::write(logos.join("logo.png"), b"abc").unwrap(); + assert_eq!( + to_data_url_in(&uploads, &temp, "/uploads/logos/logo.png") + .await + .unwrap(), + "data:image/png;base64,YWJj" + ); + // Fichier absent : l'URL est rendue telle quelle + assert_eq!( + to_data_url_in(&uploads, &temp, "/uploads/logos/absent.png") + .await + .unwrap(), + "/uploads/logos/absent.png" + ); + let _ = std::fs::remove_dir_all(uploads.parent().unwrap()); + } + + #[test] + fn normalisation_lexicale() { + assert_eq!( + normalize(Path::new("/a/b/../c/./d")), + PathBuf::from("/a/c/d") + ); + assert_eq!(normalize(Path::new("/a/../../etc")), PathBuf::from("/etc")); + } +} diff --git a/server/src/services/reels/tts_sync.rs b/server/src/services/reels/tts_sync.rs new file mode 100644 index 0000000..b6863a1 --- /dev/null +++ b/server/src/services/reels/tts_sync.rs @@ -0,0 +1,109 @@ +//! Estimation de la durée de lecture d'un texte, affichée pendant la saisie +//! (portage de `server/services/ttsSync.ts`). +//! +//! Le minutage réel des mots vient de la voix au moment du rendu ; ici, une +//! estimation suffit et ne déclenche aucune synthèse (payante avec Gemini). + +use once_cell::sync::Lazy; +use regex::Regex; +use serde::Serialize; + +/// Débit moyen d'une voix de synthèse française, en mots par seconde. +const WORDS_PER_SECOND: f64 = 2.6; +/// Au-delà, la voix dépasse la durée confortable d'un Reel. +const MAX_COMFORTABLE_SECONDS: f64 = 45.0; +const PUNCTUATION_PAUSE: f64 = 0.35; + +static HASHTAG: Lazy = + Lazy::new(|| Regex::new(r"#[A-Za-z0-9_\x{C0}-\x{FF}]+").expect("regex")); +static LINK: Lazy = Lazy::new(|| Regex::new(r"https?://\S+").expect("regex")); +static WORDLIKE: Lazy = + Lazy::new(|| Regex::new(r"[A-Za-z0-9\x{C0}-\x{FF}]").expect("regex")); +static PAUSE: Lazy = Lazy::new(|| Regex::new(r"[.!?;:]").expect("regex")); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncTiming { + pub word_duration: f64, + pub audio_duration: f64, + pub word_count: usize, + pub punctuation_pause: f64, + pub is_healthy: bool, + pub warnings: Vec, +} + +/// `Math.round` de JavaScript (les demis arrondis vers le haut). +fn js_round(value: f64) -> f64 { + (value + 0.5).floor() +} + +pub fn estimate_voice_timing(text: &str) -> SyncTiming { + let without_tags = HASHTAG.replace_all(text, ""); + let cleaned = LINK.replace_all(&without_tags, ""); + let word_count = cleaned + .split(char::is_whitespace) + .filter(|w| WORDLIKE.is_match(w)) + .count(); + let pauses = PAUSE.find_iter(text).count(); + let audio_duration = word_count as f64 / WORDS_PER_SECOND + pauses as f64 * PUNCTUATION_PAUSE; + + let mut warnings = Vec::new(); + if audio_duration > MAX_COMFORTABLE_SECONDS { + warnings.push(format!( + "Texte long : environ {} s de voix. Visez moins de {} s.", + js_round(audio_duration), + MAX_COMFORTABLE_SECONDS + )); + } + if word_count > 0 && word_count < 4 { + warnings.push("Texte très court : la voix ne durera que quelques secondes.".into()); + } + + SyncTiming { + word_duration: if word_count > 0 { + audio_duration / word_count as f64 + } else { + 0.0 + }, + audio_duration, + word_count, + punctuation_pause: PUNCTUATION_PAUSE, + is_healthy: warnings.is_empty(), + warnings, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ignore_hashtags_et_liens() { + assert_eq!( + estimate_voice_timing("Venez vite #promo https://x.fr").word_count, + 2 + ); + } + + #[test] + fn previent_quand_la_voix_sera_trop_longue() { + let timing = estimate_voice_timing(&"mot ".repeat(200)); + assert!(!timing.is_healthy); + assert!(timing.warnings[0].starts_with("Texte long")); + assert_eq!( + timing.warnings[0], + "Texte long : environ 77 s de voix. Visez moins de 45 s." + ); + } + + #[test] + fn texte_court() { + let timing = estimate_voice_timing("Bonjour !"); + assert_eq!(timing.word_count, 1); + assert_eq!( + timing.warnings, + vec!["Texte très court : la voix ne durera que quelques secondes."] + ); + assert!((timing.audio_duration - (1.0 / 2.6 + 0.35)).abs() < 1e-9); + } +}