import { execFile } from "node:child_process"; import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { promisify } from "node:util"; import { resolve } from "node:path"; import { dbAll, dbGet, dbRun } from "./db.mjs"; import { addAudit, httpError, requirePermission } from "./tenant.mjs"; import { registerCompositionArtifact } from "./media-artifacts.mjs"; const execFileAsync = promisify(execFile); const projectRoot = resolve(import.meta.dirname, ".."); const ffmpegBinary = process.env.FFMPEG_BIN || "ffmpeg"; const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; function safeRelativePath(value, label) { const relative = String(value || "").replace(/^[/\\]+/, ""); if (!relative || relative.includes("..") || !relative.startsWith("storage/")) throw httpError(400, "composition_path_invalid", `${label}必须是 storage/ 下的安全相对路径`); const absolute = resolve(projectRoot, relative); if (!absolute.startsWith(`${projectRoot}/storage/`)) throw httpError(400, "composition_path_invalid", `${label}越过了本地存储根目录`); return { relative, absolute }; } async function exists(filePath) { try { const info = await stat(filePath); return info.isFile(); } catch { return false; } } function concatLine(pathname) { return `file '${pathname.replaceAll("'", "'\\''")}'`; } function compositionPayload(row) { if (!row) return null; let source = {}; let result = {}; try { source = JSON.parse(row.source_json); } catch { source = {}; } try { result = JSON.parse(row.result_json); } catch { result = {}; } return { ...row, dryRun: Boolean(row.dry_run), source, result }; } async function ffmpegVersion() { try { const result = await execFileAsync(ffmpegBinary, ["-version"], { timeout: 5000 }); return String(result.stdout || "").split("\n")[0] || "ffmpeg"; } catch (error) { throw httpError(503, "ffmpeg_unavailable", `本地 FFmpeg 不可用:${String(error.message || error).slice(0, 300)}`); } } function scopedEpisode(context, episodeId) { if (!episodeId) return null; const row = dbGet( `SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE e.id = ? AND sr.project_id = ?`, [episodeId, context.project.id] ); if (!row) throw httpError(400, "episode_invalid", "合成分集不属于当前项目"); return row; } export async function composeProject(context, body = {}) { requirePermission(context, "delivery:approve"); if (!context.project) throw httpError(400, "project_required", "本地合成必须绑定项目"); const clips = Array.isArray(body.clips) ? body.clips.map((item) => String(item || "").trim()).filter(Boolean) : []; if (!clips.length || clips.length > 200) throw httpError(400, "composition_clips_invalid", "本地合成需要 1 到 200 个片段"); const clipFiles = clips.map((clip) => safeRelativePath(clip, "片段路径")); const audio = body.audioPath ? safeRelativePath(body.audioPath, "音频路径") : null; const version = String(body.version || `local-${new Date().toISOString().slice(0, 10)}-${Date.now()}`).trim(); const dryRun = Boolean(body.dryRun); const compositionId = makeId("composition"); const compositionRoot = resolve(projectRoot, "storage", "compositions", compositionId); const manifestPath = `storage/compositions/${compositionId}/concat.txt`; const outputPath = safeRelativePath(body.outputPath || `storage/compositions/${compositionId}/final.mp4`, "输出路径"); const inputStatus = []; for (const clip of clipFiles) inputStatus.push({ path: clip.relative, exists: await exists(clip.absolute) }); if (audio) inputStatus.push({ path: audio.relative, kind: "audio", exists: await exists(audio.absolute) }); const versionLabel = await ffmpegVersion(); const source = { clips: clipFiles.map((item) => item.relative), audioPath: audio?.relative || null, version, inputStatus }; const timestamp = new Date().toISOString(); dbRun("INSERT INTO media_compositions(id, organization_id, workspace_id, project_id, episode_id, version, status, tool, dry_run, output_path, manifest_path, source_json, result_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'ffmpeg', ?, ?, ?, ?, '{}', ?, ?, ?)", [compositionId, context.organization.id, context.workspace.id, context.project.id, scopedEpisode(context, body.episodeId)?.id || null, version, dryRun ? "planned" : "running", dryRun ? 1 : 0, outputPath.relative, manifestPath, JSON.stringify(source), context.user.id, timestamp, timestamp]); if (dryRun) { const result = { dryRun: true, tool: "ffmpeg", version: versionLabel, manifestPath, outputPath: outputPath.relative, command: [ffmpegBinary, "-f", "concat", "-safe", "0", "-i", manifestPath, "-c:v", "libx264", "-pix_fmt", "yuv420p", outputPath.relative], inputStatus }; dbRun("UPDATE media_compositions SET result_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(result), timestamp, compositionId]); addAudit({ context, action: "media.composition.planned", targetType: "media_composition", targetId: compositionId, metadata: result }); return { composition: { ...compositionPayload(dbGet("SELECT * FROM media_compositions WHERE id = ?", [compositionId])), ...result } }; } const missing = inputStatus.filter((item) => !item.exists); if (missing.length) { const message = `输入片段不存在:${missing.map((item) => item.path).join(", ")}`; dbRun("UPDATE media_compositions SET status = 'failed', error_message = ?, updated_at = ? WHERE id = ?", [message, now(), compositionId]); throw httpError(422, "composition_input_missing", message, { compositionId, missing }); } await mkdir(compositionRoot, { recursive: true }); await mkdir(resolve(outputPath.absolute, ".."), { recursive: true }); await writeFile(resolve(projectRoot, manifestPath), `${clipFiles.map((item) => concatLine(item.absolute)).join("\n")}\n`, "utf8"); const args = ["-y", "-f", "concat", "-safe", "0", "-i", resolve(projectRoot, manifestPath)]; if (audio) args.push("-i", audio.absolute); args.push("-map", "0:v:0", "-map", audio ? "1:a:0?" : "0:a:0?", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "+faststart", "-shortest", outputPath.absolute); try { const result = await execFileAsync(ffmpegBinary, args, { timeout: 20 * 60 * 1000, maxBuffer: 2 * 1024 * 1024 }); const outputInfo = await stat(outputPath.absolute); const composition = dbGet("SELECT * FROM media_compositions WHERE id = ?", [compositionId]); const artifact = await registerCompositionArtifact(context, composition, outputPath.relative); const payload = { dryRun: false, tool: "ffmpeg", version: versionLabel, outputPath: outputPath.relative, outputBytes: outputInfo.size, artifact, stderr: String(result.stderr || "").slice(-2000) }; const finishedAt = new Date().toISOString(); dbRun("UPDATE media_compositions SET status = 'completed', result_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(payload), finishedAt, compositionId]); addAudit({ context, action: "media.composition.completed", targetType: "media_composition", targetId: compositionId, metadata: payload }); return { composition: { ...compositionPayload(dbGet("SELECT * FROM media_compositions WHERE id = ?", [compositionId])), ...payload } }; } catch (error) { const message = String(error.stderr || error.message || error).slice(-2000); dbRun("UPDATE media_compositions SET status = 'failed', error_message = ?, updated_at = ? WHERE id = ?", [message, new Date().toISOString(), compositionId]); addAudit({ context, action: "media.composition.failed", targetType: "media_composition", targetId: compositionId, result: "error", metadata: { error: message } }); throw httpError(502, "composition_failed", message, { compositionId }); } } function now() { return new Date().toISOString(); } export function listCompositions(context, limit = 40) { if (!context.project) return []; return dbAll("SELECT * FROM media_compositions WHERE organization_id = ? AND workspace_id = ? AND project_id = ? ORDER BY created_at DESC LIMIT ?", [context.organization.id, context.workspace.id, context.project.id, Math.max(1, Math.min(100, Number(limit || 40))) ]).map(compositionPayload); }