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 0fb6133..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,15 +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. +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 { - anyhow::bail!("service FFmpeg non implémenté") + 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); + } +}