From 61f95a15fe90b19177abeab60745922d87c1909a Mon Sep 17 00:00:00 2001 From: leon-morival Date: Wed, 1 Oct 2025 10:23:49 +0200 Subject: [PATCH] thumbnailUrl --- functions/index.js | 1 + functions/package-lock.json | 184 +++++++++++++++++++++++++++--------- functions/package.json | 8 +- functions/src/hls.js | 86 ++++++++--------- functions/src/thumbnail.js | 119 +++++++++++++++++++++++ 5 files changed, 306 insertions(+), 92 deletions(-) create mode 100644 functions/src/thumbnail.js diff --git a/functions/index.js b/functions/index.js index 530300d..60c7eba 100644 --- a/functions/index.js +++ b/functions/index.js @@ -14,3 +14,4 @@ exports.lyrics = require("./src/lyrics"); exports.cover = require("./src/cover"); exports.projects = require("./src/project"); exports.hls = require("./src/hls"); +exports.thumbnail = require("./src/thumbnail"); diff --git a/functions/package-lock.json b/functions/package-lock.json index 0e8567f..41f986c 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -6,12 +6,14 @@ "": { "name": "functions", "dependencies": { + "@ffmpeg-installer/ffmpeg": "^1.1.0", "@genkit-ai/googleai": "^1.18.0", + "@google-cloud/storage": "^7.17.1", "axios": "^1.11.0", "ffmpeg-static": "^5.2.0", "firebase-admin": "^12.7.0", "firebase-functions": "^6.4.0", - "fluent-ffmpeg": "^2.1.2", + "fluent-ffmpeg": "^2.1.3", "genkit": "^1.18.0" }, "devDependencies": { @@ -128,6 +130,132 @@ "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", "license": "MIT" }, + "node_modules/@ffmpeg-installer/darwin-arm64": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz", + "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffmpeg-installer/darwin-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz", + "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "LGPL-2.1", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffmpeg-installer/ffmpeg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz", + "integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==", + "license": "LGPL-2.1", + "optionalDependencies": { + "@ffmpeg-installer/darwin-arm64": "4.1.5", + "@ffmpeg-installer/darwin-x64": "4.1.0", + "@ffmpeg-installer/linux-arm": "4.1.3", + "@ffmpeg-installer/linux-arm64": "4.1.4", + "@ffmpeg-installer/linux-ia32": "4.1.0", + "@ffmpeg-installer/linux-x64": "4.1.0", + "@ffmpeg-installer/win32-ia32": "4.1.0", + "@ffmpeg-installer/win32-x64": "4.1.0" + } + }, + "node_modules/@ffmpeg-installer/linux-arm": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz", + "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==", + "cpu": [ + "arm" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-arm64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz", + "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-ia32": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz", + "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==", + "cpu": [ + "ia32" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz", + "integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/win32-ia32": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz", + "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==", + "cpu": [ + "ia32" + ], + "license": "GPLv3", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@ffmpeg-installer/win32-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz", + "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==", + "cpu": [ + "x64" + ], + "license": "GPLv3", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@firebase/app-check-interop-types": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz", @@ -521,7 +649,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", "license": "Apache-2.0", - "optional": true, "dependencies": { "arrify": "^2.0.0", "extend": "^3.0.2" @@ -545,7 +672,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=14.0.0" } @@ -555,17 +681,15 @@ "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=14" } }, "node_modules/@google-cloud/storage": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.0.tgz", - "integrity": "sha512-5m9GoZqKh52a1UqkxDBu/+WVFDALNtHg5up5gNmNbXQWBcV813tzJKsyDtKjOPrlR1em1TxtD7NSPCrObH7koQ==", + "version": "7.17.1", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.1.tgz", + "integrity": "sha512-2FMQbpU7qK+OtBPaegC6n+XevgZksobUGo6mGKnXNmeZpvLiAo1gTAE3oTKsrMGDV4VtL8Zzpono0YsK/Q7Iqg==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@google-cloud/paginator": "^5.0.0", "@google-cloud/projectify": "^4.0.0", @@ -592,7 +716,6 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -2240,7 +2363,6 @@ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "license": "MIT", - "optional": true, "engines": { "node": ">= 10" } @@ -2276,8 +2398,7 @@ "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/connect": { "version": "3.4.38", @@ -2440,7 +2561,6 @@ "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", "license": "MIT", - "optional": true, "dependencies": { "@types/caseless": "*", "@types/node": "*", @@ -2453,7 +2573,6 @@ "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", "license": "MIT", - "optional": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -2507,8 +2626,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/triple-beam": { "version": "1.3.5", @@ -2529,7 +2647,6 @@ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", - "optional": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -2696,7 +2813,6 @@ "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -2722,7 +2838,6 @@ "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", "license": "MIT", - "optional": true, "dependencies": { "retry": "0.13.1" } @@ -3239,7 +3354,6 @@ "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", "license": "MIT", - "optional": true, "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", @@ -3289,7 +3403,6 @@ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -3563,7 +3676,6 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=6" } @@ -3714,7 +3826,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "strnum": "^1.1.1" }, @@ -4520,8 +4631,7 @@ "url": "https://patreon.com/mdevils" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/http-errors": { "version": "2.0.0", @@ -4550,7 +4660,6 @@ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", "license": "MIT", - "optional": true, "dependencies": { "@tootallnate/once": "2", "agent-base": "6", @@ -4565,7 +4674,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", - "optional": true, "dependencies": { "debug": "4" }, @@ -5163,7 +5271,6 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", "license": "MIT", - "optional": true, "bin": { "mime": "cli.js" }, @@ -5348,7 +5455,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -5395,7 +5501,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "devOptional": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -5832,7 +5937,6 @@ "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 4" } @@ -5842,7 +5946,6 @@ "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", "license": "MIT", - "optional": true, "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", @@ -6177,7 +6280,6 @@ "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", "license": "MIT", - "optional": true, "dependencies": { "stubs": "^3.0.0" } @@ -6186,8 +6288,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/string_decoder": { "version": "1.3.0", @@ -6252,15 +6353,13 @@ "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/stubs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/supports-color": { "version": "7.2.0", @@ -6292,7 +6391,6 @@ "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", "license": "Apache-2.0", - "optional": true, "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", @@ -6309,7 +6407,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", - "optional": true, "dependencies": { "debug": "4" }, @@ -6322,7 +6419,6 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", - "optional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -6336,7 +6432,6 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", - "optional": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -6361,7 +6456,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -6708,7 +6802,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "devOptional": true, "license": "ISC" }, "node_modules/xorshift": { @@ -6784,7 +6877,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/functions/package.json b/functions/package.json index fda7ae9..b490321 100644 --- a/functions/package.json +++ b/functions/package.json @@ -14,13 +14,15 @@ }, "main": "index.js", "dependencies": { + "@ffmpeg-installer/ffmpeg": "^1.1.0", "@genkit-ai/googleai": "^1.18.0", + "@google-cloud/storage": "^7.17.1", "axios": "^1.11.0", + "ffmpeg-static": "^5.2.0", "firebase-admin": "^12.7.0", "firebase-functions": "^6.4.0", - "genkit": "^1.18.0", - "fluent-ffmpeg": "^2.1.2", - "ffmpeg-static": "^5.2.0" + "fluent-ffmpeg": "^2.1.3", + "genkit": "^1.18.0" }, "devDependencies": { "eslint": "^8.15.0", diff --git a/functions/src/hls.js b/functions/src/hls.js index 2b52d80..172bafc 100644 --- a/functions/src/hls.js +++ b/functions/src/hls.js @@ -1,20 +1,20 @@ -const { onObjectFinalized } = require('firebase-functions/v2/storage'); -const functions = require('firebase-functions'); -const admin = require('firebase-admin'); -const path = require('path'); -const os = require('os'); -const fs = require('fs'); +const { onObjectFinalized } = require("firebase-functions/v2/storage"); +const functions = require("firebase-functions"); +const admin = require("firebase-admin"); +const path = require("path"); +const os = require("os"); +const fs = require("fs"); // These deps must be installed in functions/: ffmpeg-static, fluent-ffmpeg let ffmpeg; let ffmpegPath; try { - ffmpeg = require('fluent-ffmpeg'); - ffmpegPath = require('ffmpeg-static'); + ffmpeg = require("fluent-ffmpeg"); + ffmpegPath = require("ffmpeg-static"); ffmpeg.setFfmpegPath(ffmpegPath); } catch (e) { console.log( - 'ffmpeg modules not installed yet — deploy after npm i', + "ffmpeg modules not installed yet — deploy after npm i", e?.message ); } @@ -23,56 +23,56 @@ const bucket = admin.storage().bucket(); exports.transcodePlaybackToHLS = onObjectFinalized( { - region: 'europe-west1', - memory: '2GiB', + region: "europe-west1", + memory: "2GiB", timeoutSeconds: 540, cpu: 2, }, async (event) => { const object = event.data; - const filePath = object?.name || ''; // e.g., musics/{projectId}/source.mp4 - const contentType = object?.contentType || ''; + const filePath = object?.name || ""; // e.g., musics/{projectId}/source.mp4 + const contentType = object?.contentType || ""; try { // Only process our target path + video files const match = filePath.match(/^musics\/(.+?)\/source\.mp4$/i); - if (!match || !contentType.startsWith('video/')) { + if (!match || !contentType.startsWith("video/")) { return; } const projectId = match[1]; const tempDir = fs.mkdtempSync( path.join(os.tmpdir(), `hls_${projectId}_`) ); - const localSource = path.join(tempDir, 'source.mp4'); - const hlsDir = path.join(tempDir, 'hls'); + const localSource = path.join(tempDir, "source.mp4"); + const hlsDir = path.join(tempDir, "hls"); fs.mkdirSync(hlsDir); // Download source await bucket.file(filePath).download({ destination: localSource }); if (!ffmpeg) { - console.warn('ffmpeg not available; skip transcode'); + console.warn("ffmpeg not available; skip transcode"); return; } // Transcode to HLS (single rendition for simplicity) - const masterPath = path.join(hlsDir, 'master.m3u8'); - const segmentPattern = path.join(hlsDir, 'segment_%03d.ts'); + const masterPath = path.join(hlsDir, "master.m3u8"); + const segmentPattern = path.join(hlsDir, "segment_%03d.ts"); await new Promise((resolve, reject) => { ffmpeg(localSource) .outputOptions([ - '-profile:v baseline', - '-level 3.0', - '-start_number 0', - '-hls_time 4', - '-hls_list_size 0', - '-f hls', + "-profile:v baseline", + "-level 3.0", + "-start_number 0", + "-hls_time 4", + "-hls_list_size 0", + "-f hls", `-hls_segment_filename ${segmentPattern}`, ]) .output(masterPath) - .on('end', resolve) - .on('error', reject) + .on("end", resolve) + .on("error", reject) .run(); }); @@ -82,28 +82,28 @@ exports.transcodePlaybackToHLS = onObjectFinalized( // Upload all segments first const uploads = fileNames - .filter((n) => n.endsWith('.ts')) + .filter((n) => n.endsWith(".ts")) .map((name) => { const local = path.join(hlsDir, name); const dest = `${destPrefix}${name}`; return bucket.upload(local, { destination: dest, metadata: { - contentType: 'video/MP2T', - cacheControl: 'public, max-age=3600', + contentType: "video/MP2T", + cacheControl: "public, max-age=3600", }, }); }); await Promise.all(uploads); // Generate signed URLs for segments and rewrite playlist with absolute URLs - const segmentNames = fileNames.filter((n) => n.endsWith('.ts')); + const segmentNames = fileNames.filter((n) => n.endsWith(".ts")); const signedSegments = {}; await Promise.all( segmentNames.map(async (name) => { const file = bucket.file(`${destPrefix}${name}`); const [url] = await file.getSignedUrl({ - action: 'read', + action: "read", expires: Date.now() + 1000 * 60 * 60 * 24 * 365, }); signedSegments[name] = url; @@ -111,24 +111,24 @@ exports.transcodePlaybackToHLS = onObjectFinalized( ); // Rewrite master playlist to use absolute signed segment URLs - let masterContent = fs.readFileSync(masterPath, 'utf8'); + let masterContent = fs.readFileSync(masterPath, "utf8"); masterContent = masterContent .split(/\r?\n/) .map((line) => - line.endsWith('.ts') && signedSegments[line] + line.endsWith(".ts") && signedSegments[line] ? signedSegments[line] : line ) - .join('\n'); - const rewrittenMasterLocal = path.join(hlsDir, 'master.abs.m3u8'); - fs.writeFileSync(rewrittenMasterLocal, masterContent, 'utf8'); + .join("\n"); + const rewrittenMasterLocal = path.join(hlsDir, "master.abs.m3u8"); + fs.writeFileSync(rewrittenMasterLocal, masterContent, "utf8"); // Upload rewritten playlist await bucket.upload(rewrittenMasterLocal, { destination: `${destPrefix}master.m3u8`, metadata: { - contentType: 'application/vnd.apple.mpegurl', - cacheControl: 'public, max-age=3600', + contentType: "application/vnd.apple.mpegurl", + cacheControl: "public, max-age=3600", }, }); @@ -136,15 +136,15 @@ exports.transcodePlaybackToHLS = onObjectFinalized( const [signedUrl] = await bucket .file(`${destPrefix}master.m3u8`) .getSignedUrl({ - action: 'read', + action: "read", expires: Date.now() + 1000 * 60 * 60 * 24 * 365, }); // Update Firestore project doc with playbackUrl - await admin.firestore().collection('projects').doc(projectId).set( + await admin.firestore().collection("projects").doc(projectId).set( { playbackUrl: signedUrl, - playbackStatus: 'ready', + playbackStatus: "ready", updatedAt: admin.firestore.FieldValue.serverTimestamp(), }, { merge: true } @@ -155,7 +155,7 @@ exports.transcodePlaybackToHLS = onObjectFinalized( fs.rmSync(tempDir, { recursive: true, force: true }); } catch (e) {} } catch (e) { - console.error('HLS transcode error', e); + console.error("HLS transcode error", e); } } ); diff --git a/functions/src/thumbnail.js b/functions/src/thumbnail.js new file mode 100644 index 0000000..6d3db99 --- /dev/null +++ b/functions/src/thumbnail.js @@ -0,0 +1,119 @@ +const { onObjectFinalized } = require("firebase-functions/v2/storage"); +const logger = require("firebase-functions/logger"); +const admin = require("firebase-admin"); +const ffmpeg = require("fluent-ffmpeg"); +const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const crypto = require("node:crypto"); + +ffmpeg.setFfmpegPath(ffmpegInstaller.path); + +exports.generateVideoThumbnail = onObjectFinalized( + { + region: "europe-west1", + timeoutSeconds: 180, + memory: "1GiB", + cpu: 1, + }, + async (event) => { + const file = event.data || {}; + const bucketName = file.bucket; + const objectName = file.name || ""; + const contentType = file.contentType || ""; + + if (!bucketName) return; + if (!contentType.startsWith("video/")) return; + if (!objectName) return; + if (/_thumb9x16\.jpg$/i.test(objectName)) return; + + const bucket = admin.storage().bucket(bucketName); + const playbackMatch = objectName.match(/^musics\/([^/]+)\/playback\.mp4$/i); + const projectId = playbackMatch ? playbackMatch[1] : null; + + // Use unique folder under /tmp to avoid name collisions + const tmpDir = path.join( + os.tmpdir(), + `thumb-${Date.now()}-${Math.round(Math.random() * 1000)}` + ); + const baseName = path.basename(objectName); + const dirName = path.posix.dirname(objectName); + const localVideoPath = path.join(tmpDir, baseName); + + const thumbBase = baseName.replace(/\.[^.]+$/, "") + "_thumb9x16.jpg"; + const localThumbPath = path.join(tmpDir, thumbBase); + const remoteThumbPath = + dirName && dirName !== "." + ? path.posix.join(dirName, thumbBase) + : thumbBase; + + try { + await fs.mkdir(tmpDir, { recursive: true }); + + // Télécharger la vidéo depuis le bucket + await bucket.file(objectName).download({ destination: localVideoPath }); + + // Extraire 1 frame à 1 seconde, forcer 9:16 (1080x1920) par scale+crop centré + await new Promise((resolve, reject) => { + ffmpeg(localVideoPath) + .inputOptions(["-ss 1"]) + .frames(1) + .outputOptions([ + "-vf", + "scale='if(gt(a,9/16),1080,-2)':'if(gt(a,9/16),-2,1920)',crop=1080:1920", + "-q:v", + "2", + ]) + .output(localThumbPath) + .on("end", resolve) + .on("error", reject) + .run(); + }); + + // Upload du thumbnail avec un token de téléchargement public Firebase + const downloadToken = crypto.randomUUID(); + await bucket.upload(localThumbPath, { + destination: remoteThumbPath, + metadata: { + contentType: "image/jpeg", + cacheControl: "public, max-age=86400", + metadata: { + original: objectName, + aspect: "9:16", + t: "1s", + firebaseStorageDownloadTokens: downloadToken, + }, + }, + }); + + const encodedPath = encodeURIComponent(remoteThumbPath); + const thumbnailUrl = `https://firebasestorage.googleapis.com/v0/b/${bucketName}/o/${encodedPath}?alt=media&token=${downloadToken}`; + + if (projectId) { + await admin.firestore().collection("projects").doc(projectId).set( + { + thumbnailUrl, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }, + { merge: true } + ); + } + + logger.info("✅ [Thumbnail] Uploaded", { + objectName, + remoteThumbPath, + projectId, + thumbnailUrl, + }); + } catch (error) { + logger.error("❌ [Thumbnail] Failed", { + objectName, + error: error?.message || String(error), + }); + throw error; + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + } +);