diff --git a/package-lock.json b/package-lock.json index cda85a9..f586e86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,13 @@ "@prisma/client": "^6.4.1", "fastify": "^5.2.1", "fastify-plugin": "^5.0.1", + "ffmpeg-static": "^5.2.0", + "fluent-ffmpeg": "^2.1.3", "jose": "^6.0.8", "undici": "^7.3.0" }, "devDependencies": { + "@types/fluent-ffmpeg": "^2.1.27", "@types/node": "^22.13.4", "@types/ws": "^8.18.1", "prisma": "^6.4.1", @@ -28,6 +31,21 @@ "vitest": "^3.0.5" } }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "license": "MIT", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1197,6 +1215,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/fluent-ffmpeg": { + "version": "2.1.28", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.28.tgz", + "integrity": "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "22.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", @@ -1338,6 +1366,18 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -1381,6 +1421,11 @@ "node": ">=12" } }, + "node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1410,6 +1455,12 @@ "fastq": "^1.17.1" } }, + "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/c12": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", @@ -1449,6 +1500,12 @@ "node": ">=8" } }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1502,6 +1559,21 @@ "consola": "^3.2.3" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -1536,7 +1608,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1648,6 +1719,15 @@ "once": "^1.4.0" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1884,6 +1964,22 @@ } } }, + "node_modules/ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "hasInstallScript": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/find-my-way": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", @@ -1898,6 +1994,20 @@ "node": ">=20" } }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", + "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "async": "^0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1944,6 +2054,34 @@ "giget": "dist/cli.mjs" } }, + "node_modules/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "license": "MIT", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1959,6 +2097,12 @@ "node": ">= 10" } }, + "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/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -2077,7 +2221,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2162,6 +2305,11 @@ "wrappy": "1" } }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2326,6 +2474,15 @@ ], "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -2750,6 +2907,12 @@ "fsevents": "~2.3.3" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2964,6 +3127,18 @@ "dev": true, "license": "MIT" }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index 0d2bd4a..6306153 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,13 @@ "fastify": "^5.2.1", "fastify-plugin": "^5.0.1", "jose": "^6.0.8", + "fluent-ffmpeg": "^2.1.3", + "ffmpeg-static": "^5.2.0", "undici": "^7.3.0" }, "devDependencies": { "@types/node": "^22.13.4", + "@types/fluent-ffmpeg": "^2.1.27", "@types/ws": "^8.18.1", "prisma": "^6.4.1", "tsx": "^4.19.3", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 15c769e..0cead67 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -166,6 +166,7 @@ model Video { videoUrl String fileSize Int? isPublic Boolean @default(true) + sourcePlanId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt likes VideoLike[] diff --git a/src/app.ts b/src/app.ts index 9580b4c..5992e4c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,6 +24,7 @@ import { createChatRoutes } from './routes/chat/websocket.js'; import { createSignalingRoutes } from './routes/signaling/websocket.js'; import deviceRoutes from './routes/devices/status.js'; import videoRoutes from './routes/content/videos.js'; +import thumbnailRoutes from './routes/media/thumbnails.js'; import { ChatManager } from './services/chat-manager.service.js'; import { SignalingManager } from './services/signaling-manager.service.js'; import { config } from './config.js'; @@ -77,6 +78,7 @@ export async function buildApp() { await app.register(createSignalingRoutes(signalingManager)); await app.register(deviceRoutes); await app.register(videoRoutes); + await app.register(thumbnailRoutes); return app; } diff --git a/src/routes/content/videos.ts b/src/routes/content/videos.ts index 78d4aaa..6630586 100644 --- a/src/routes/content/videos.ts +++ b/src/routes/content/videos.ts @@ -5,6 +5,7 @@ import { createReadStream, existsSync, mkdirSync, statSync } from 'fs'; import { join } from 'path'; import { pipeline } from 'stream/promises'; import { createWriteStream } from 'fs'; +import { generateMediaAssets } from '../../services/media-processing.service.js'; const VIDEOS_DIR = join(process.cwd(), 'data', 'videos'); @@ -102,6 +103,9 @@ const videoRoutes: FastifyPluginAsync = async (fastify) => { }, }); + // Generate thumbnails async (fire-and-forget) + generateMediaAssets(filePath, video.id, request.log).catch(() => {}); + return { id: updated.id, title: updated.title, diff --git a/src/routes/media/thumbnails.ts b/src/routes/media/thumbnails.ts new file mode 100644 index 0000000..8f04b1d --- /dev/null +++ b/src/routes/media/thumbnails.ts @@ -0,0 +1,63 @@ +import { FastifyPluginAsync } from 'fastify'; +import { join } from 'path'; +import { existsSync, createReadStream, statSync } from 'fs'; +import { THUMBNAILS_DIR } from '../../services/media-processing.service.js'; + +const thumbnailRoutes: FastifyPluginAsync = async (fastify) => { + // GET /media/thumbnails/:filename — serve thumbnail/poster/clip files + fastify.get<{ Params: { filename: string } }>('/media/thumbnails/:filename', { + schema: { + params: { + type: 'object', + required: ['filename'], + properties: { filename: { type: 'string' } }, + }, + }, + }, async (request, reply) => { + const { filename } = request.params; + + // Sanitize filename — only allow alphanumeric, hyphens, underscores, dots + if (!/^[\w\-.]+$/.test(filename)) { + return reply.status(400).send({ error: 'Invalid filename' }); + } + + const filePath = join(THUMBNAILS_DIR, filename); + if (!existsSync(filePath)) { + return reply.status(404).send({ error: 'Not found' }); + } + + const stat = statSync(filePath); + const ext = filename.split('.').pop()?.toLowerCase(); + const contentType = ext === 'jpg' || ext === 'jpeg' + ? 'image/jpeg' + : ext === 'mp4' + ? 'video/mp4' + : 'application/octet-stream'; + + reply.header('Content-Type', contentType); + reply.header('Content-Length', stat.size); + reply.header('Cache-Control', 'public, max-age=3600'); + reply.header('Accept-Ranges', 'bytes'); + + const range = request.headers.range; + if (range && contentType === 'video/mp4') { + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1; + + if (start >= stat.size || end >= stat.size || start > end) { + reply.status(416).header('Content-Range', `bytes */${stat.size}`).send(); + return; + } + + reply.status(206); + reply.header('Content-Range', `bytes ${start}-${end}/${stat.size}`); + reply.header('Content-Length', end - start + 1); + return reply.send(createReadStream(filePath, { start, end })); + } + + return reply.send(createReadStream(filePath)); + }); +}; + +export default thumbnailRoutes; diff --git a/src/routes/social/feed.ts b/src/routes/social/feed.ts index 3198e79..c844975 100644 --- a/src/routes/social/feed.ts +++ b/src/routes/social/feed.ts @@ -3,6 +3,7 @@ import { existsSync } from 'fs'; import { join } from 'path'; import { optionalAuth } from '../../middleware/require-auth.js'; import { PREVIEWS_DIR } from '../streams/preview.js'; +import { hasMediaAssets, THUMBNAILS_DIR } from '../../services/media-processing.service.js'; import { autoDetectEndedPlans } from '../../services/stream-status.service.js'; import type { FeedResponse, FeedItemResponse } from '../../types/api.js'; @@ -35,6 +36,11 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => { } else if (filter === 'following' && !request.userId) { // Not logged in — return empty following feed return { items: [], nextCursor: null } as FeedResponse; + } else if (filter === 'mine') { + if (!request.userId) { + return { items: [], nextCursor: null } as FeedResponse; + } + baseWhere.userId = request.userId; } let orderBy: any; @@ -95,6 +101,7 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => { const feedItems: FeedItemResponse[] = items.map(plan => { const hasPreview = existsSync(join(PREVIEWS_DIR, `${plan.id}.mp4`)); + const assets = hasMediaAssets(plan.id); return { plan: { id: plan.id, @@ -125,6 +132,9 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => { user: plan.user, }, previewUrl: hasPreview ? `/streams/plans/${plan.id}/preview` : null, + posterUrl: assets.poster ? `/media/thumbnails/${plan.id}_poster.jpg` : null, + thumbnailUrl: assets.thumb ? `/media/thumbnails/${plan.id}_thumb.jpg` : null, + clipUrl: assets.clip ? `/media/thumbnails/${plan.id}_clip.mp4` : null, likeCount: plan._count.likes, commentCount: 0, // portal comments are ephemeral via WebSocket isLiked: likedSet.has(plan.id), diff --git a/src/routes/streams/plans.ts b/src/routes/streams/plans.ts index b5f8392..b4d937f 100644 --- a/src/routes/streams/plans.ts +++ b/src/routes/streams/plans.ts @@ -1,11 +1,12 @@ import { FastifyPluginAsync } from 'fastify'; -import { existsSync, unlinkSync } from 'fs'; +import { existsSync, unlinkSync, copyFileSync, renameSync, statSync } from 'fs'; import { join } from 'path'; import { requireAuth } from '../../middleware/require-auth.js'; import { AppError } from '../../plugins/error-handler.js'; import { autoDetectEndedPlans } from '../../services/stream-status.service.js'; import { decrypt, encrypt } from '../../services/crypto.service.js'; import { PREVIEWS_DIR } from './preview.js'; +import { getVideoDuration, THUMBNAILS_DIR } from '../../services/media-processing.service.js'; import type { CreateStreamPlanBody, CreateDestinationBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js'; export function formatPlan(plan: any): StreamPlanResponse { @@ -223,13 +224,83 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { }); if (!plan) throw new AppError(404, 'Stream plan not found'); - request.log.info({ planId: plan.id, userId: request.userId }, 'Deleting plan'); - await fastify.prisma.streamPlan.delete({ where: { id: plan.id } }); - - // Clean up preview file if exists const previewPath = join(PREVIEWS_DIR, `${plan.id}.mp4`); - if (existsSync(previewPath)) { - try { unlinkSync(previewPath); } catch { /* ignore */ } + const hasPreview = existsSync(previewPath); + + // If plan is ENDED with a preview, preserve the stream as a Video + if (plan.status === 'ENDED' && hasPreview) { + request.log.info({ planId: plan.id, userId: request.userId }, 'Preserving ENDED plan as Video'); + + let duration = 0; + try { duration = await getVideoDuration(previewPath); } catch { /* fallback 0 */ } + const fileSize = statSync(previewPath).size; + + // Create Video record + const video = await fastify.prisma.video.create({ + data: { + userId: plan.userId, + title: plan.name, + videoUrl: '', // updated after file move + duration, + fileSize, + isPublic: plan.isPublic, + sourcePlanId: plan.id, + }, + }); + + // Move preview file to videos directory + const videosDir = join(process.cwd(), 'data', 'videos'); + const videoPath = join(videosDir, `${video.id}.mp4`); + try { + renameSync(previewPath, videoPath); + } catch { + // Cross-device: copy + delete + copyFileSync(previewPath, videoPath); + try { unlinkSync(previewPath); } catch { /* ignore */ } + } + + // Copy thumbnail assets + const suffixes = ['_poster.jpg', '_thumb.jpg', '_clip.mp4']; + for (const suffix of suffixes) { + const src = join(THUMBNAILS_DIR, `${plan.id}${suffix}`); + const dst = join(THUMBNAILS_DIR, `${video.id}${suffix}`); + if (existsSync(src)) { + try { copyFileSync(src, dst); } catch { /* ignore */ } + } + } + + // Update video with URL + await fastify.prisma.video.update({ + where: { id: video.id }, + data: { videoUrl: `/content/videos/${video.id}/stream` }, + }); + + // Delete the plan from DB (cascades destinations/likes) + await fastify.prisma.streamPlan.delete({ where: { id: plan.id } }); + + // Clean up old plan thumbnail assets + for (const suffix of suffixes) { + const src = join(THUMBNAILS_DIR, `${plan.id}${suffix}`); + if (existsSync(src)) { + try { unlinkSync(src); } catch { /* ignore */ } + } + } + } else { + // DRAFT or no preview: delete plan and clean up files + request.log.info({ planId: plan.id, userId: request.userId }, 'Deleting plan'); + await fastify.prisma.streamPlan.delete({ where: { id: plan.id } }); + + if (hasPreview) { + try { unlinkSync(previewPath); } catch { /* ignore */ } + } + // Clean up thumbnail assets + const suffixes = ['_poster.jpg', '_thumb.jpg', '_clip.mp4']; + for (const suffix of suffixes) { + const src = join(THUMBNAILS_DIR, `${plan.id}${suffix}`); + if (existsSync(src)) { + try { unlinkSync(src); } catch { /* ignore */ } + } + } } reply.status(200).send({ success: true }); diff --git a/src/routes/streams/preview.ts b/src/routes/streams/preview.ts index e0ec0b5..fdcf3cc 100644 --- a/src/routes/streams/preview.ts +++ b/src/routes/streams/preview.ts @@ -4,6 +4,7 @@ import { AppError } from '../../plugins/error-handler.js'; import { join } from 'path'; import { existsSync, mkdirSync, createReadStream, createWriteStream, statSync, unlinkSync } from 'fs'; import { pipeline } from 'stream/promises'; +import { generateMediaAssets } from '../../services/media-processing.service.js'; const PREVIEWS_DIR = join(process.cwd(), 'data', 'previews'); const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -44,6 +45,10 @@ const previewRoutes: FastifyPluginAsync = async (fastify) => { } request.log.info({ planId: plan.id }, 'Preview clip uploaded'); + + // Generate thumbnails async (fire-and-forget) + generateMediaAssets(outputPath, plan.id, request.log).catch(() => {}); + reply.status(200).send({ success: true }); }); diff --git a/src/services/media-processing.service.ts b/src/services/media-processing.service.ts new file mode 100644 index 0000000..392ea02 --- /dev/null +++ b/src/services/media-processing.service.ts @@ -0,0 +1,112 @@ +import ffmpeg from 'fluent-ffmpeg'; +import ffmpegStatic from 'ffmpeg-static'; +import { join } from 'path'; +import { existsSync, mkdirSync } from 'fs'; + +const THUMBNAILS_DIR = join(process.cwd(), 'data', 'thumbnails'); + +// Ensure thumbnails directory exists +if (!existsSync(THUMBNAILS_DIR)) { + mkdirSync(THUMBNAILS_DIR, { recursive: true }); +} + +// Set ffmpeg binary path +if (ffmpegStatic) { + ffmpeg.setFfmpegPath(ffmpegStatic as unknown as string); +} + +export { THUMBNAILS_DIR }; + +/** + * Generate poster, thumbnail, and clip from a video file. + * Runs async — caller should fire-and-forget. + */ +export async function generateMediaAssets( + inputPath: string, + outputId: string, + log?: { info: (...args: any[]) => void; error: (...args: any[]) => void }, +): Promise { + const posterPath = join(THUMBNAILS_DIR, `${outputId}_poster.jpg`); + const thumbPath = join(THUMBNAILS_DIR, `${outputId}_thumb.jpg`); + const clipPath = join(THUMBNAILS_DIR, `${outputId}_clip.mp4`); + + try { + // 1. First-frame poster (720px wide, JPEG quality 80) + await runFfmpeg( + ffmpeg(inputPath) + .screenshots({ + count: 1, + timemarks: ['0.1'], + filename: `${outputId}_poster.jpg`, + folder: THUMBNAILS_DIR, + size: '720x?', + }), + ); + + // 2. Square thumbnail (300x300, center-crop) + await new Promise((resolve, reject) => { + ffmpeg(inputPath) + .seekInput(0.1) + .frames(1) + .videoFilter('crop=min(iw\\,ih):min(iw\\,ih),scale=300:300') + .outputOptions(['-q:v', '5']) + .output(thumbPath) + .on('end', () => resolve()) + .on('error', (err) => reject(err)) + .run(); + }); + + // 3. Short clip (first 3 seconds, 480px wide, 500kbps) + await new Promise((resolve, reject) => { + ffmpeg(inputPath) + .duration(3) + .videoFilter('scale=480:-2') + .videoBitrate('500k') + .outputOptions(['-movflags', '+faststart']) + .output(clipPath) + .on('end', () => resolve()) + .on('error', (err) => reject(err)) + .run(); + }); + + log?.info({ outputId }, 'Media assets generated'); + } catch (err) { + log?.error({ outputId, err }, 'Media asset generation failed'); + } +} + +/** + * Get video duration in seconds via ffprobe. + */ +export function getVideoDuration(filePath: string): Promise { + return new Promise((resolve, reject) => { + ffmpeg.ffprobe(filePath, (err, metadata) => { + if (err) return reject(err); + resolve(Math.round(metadata.format.duration ?? 0)); + }); + }); +} + +/** + * Check if thumbnail assets exist for a given ID. + */ +export function hasMediaAssets(id: string): { + poster: boolean; + thumb: boolean; + clip: boolean; +} { + return { + poster: existsSync(join(THUMBNAILS_DIR, `${id}_poster.jpg`)), + thumb: existsSync(join(THUMBNAILS_DIR, `${id}_thumb.jpg`)), + clip: existsSync(join(THUMBNAILS_DIR, `${id}_clip.mp4`)), + }; +} + +// Helper to promisify the screenshots method +function runFfmpeg(command: ffmpeg.FfmpegCommand): Promise { + return new Promise((resolve, reject) => { + command + .on('end', () => resolve()) + .on('error', (err) => reject(err)); + }); +} diff --git a/src/types/api.ts b/src/types/api.ts index 17e71de..45549ee 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -149,6 +149,9 @@ export interface PublicUserResponse { export interface FeedItemResponse { plan: StreamPlanResponse & { user: { id: string; displayName: string; avatarUrl: string | null } }; previewUrl: string | null; + posterUrl: string | null; + thumbnailUrl: string | null; + clipUrl: string | null; likeCount: number; commentCount: number; isLiked: boolean;