Add media processing, mine feed filter, and preserve ended streams as videos

- Add ffmpeg-based media asset generation (poster, thumbnail, clip) for previews and videos
- Add GET /media/thumbnails/:filename serving route
- Add filter=mine to feed endpoint for user's own published streams
- Feed response now includes posterUrl, thumbnailUrl, clipUrl
- Deleting an ENDED plan with preview preserves it as a Video record
- Add sourcePlanId to Video schema
This commit is contained in:
2026-03-04 21:07:18 +01:00
parent 36dce50b64
commit a814dd3387
11 changed files with 458 additions and 9 deletions

179
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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[]

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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),

View File

@@ -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 });

View File

@@ -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 });
});

View File

@@ -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<void> {
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<void>((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<void>((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<number> {
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<void> {
return new Promise((resolve, reject) => {
command
.on('end', () => resolve())
.on('error', (err) => reject(err));
});
}

View File

@@ -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;