pipeline.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import { randomUUID } from "node:crypto";
  2. import { mkdir } from "node:fs/promises";
  3. import { join } from "node:path";
  4. import type {
  5. PipelineInput,
  6. PipelineJob,
  7. ExportFile,
  8. } from "@pipeline/shared";
  9. import { PLATFORM_PRESETS } from "@pipeline/shared";
  10. import { createLogger } from "@pipeline/shared/node";
  11. import { parseText } from "./stages/parse.js";
  12. import { generateTTS } from "./stages/tts.js";
  13. import { resolveAssets } from "./stages/assets.js";
  14. import { composeProject } from "./stages/compose.js";
  15. import { renderVideo } from "./stages/render.js";
  16. import { exportVideo, type PublishTargetConfig } from "./stages/export.js";
  17. import { cleanupExpiredOutput } from "./cleanup.js";
  18. import {
  19. runPublish,
  20. notifyGenerationFailure,
  21. type PublishConfig,
  22. } from "./publish/index.js";
  23. export interface PipelineConfig {
  24. branding: {
  25. channelName: string;
  26. };
  27. llm: {
  28. baseURL?: string;
  29. apiKey?: string;
  30. model: string;
  31. };
  32. tts: {
  33. provider: string;
  34. voiceId?: string;
  35. model?: string;
  36. format?: "mp3" | "wav" | "pcm";
  37. speed?: number;
  38. };
  39. alignment?: {
  40. provider: "whisper" | "native";
  41. whisperModel?: string;
  42. language?: string;
  43. };
  44. output: {
  45. dir: string;
  46. /** Auto-clean cached outputs older than this many days (0 disables). */
  47. retentionDays?: number;
  48. };
  49. /** OSS upload + Feishu notification, resolved by resolvePublishConfig. */
  50. publish?: PublishConfig;
  51. /**
  52. * Per-platform × per-template publish metadata for the sidecar manifest
  53. * (partition tid / category, extra tags). Lookup: publishMeta[platform][template].
  54. * Missing combos simply omit those keys from the manifest.
  55. */
  56. publishMeta?: Record<string, Record<string, PublishTargetConfig>>;
  57. /** Skip the publish stage entirely (e.g. local debugging). */
  58. skipPublish?: boolean;
  59. assets: {
  60. root: string;
  61. inputDir: string;
  62. };
  63. templates: {
  64. entryPoint: string;
  65. };
  66. skipTts?: boolean;
  67. skipLlm?: boolean;
  68. }
  69. export interface PipelineCallbacks {
  70. onStageStart?: (stage: string) => void;
  71. onStageComplete?: (stage: string) => void;
  72. onError?: (stage: string, error: Error) => void;
  73. }
  74. /**
  75. * Compose a suggested video title for Feishu notifications: the parsed title,
  76. * joined with the cover scene's one-line trendSummary (github-trending) via a
  77. * fullwidth separator when present; otherwise just the title. Returns undefined
  78. * when no title is known (e.g. parse failed) so the line can be omitted.
  79. */
  80. function buildTitleSuggestion(
  81. title: string | undefined,
  82. trendSummary: string | undefined
  83. ): string | undefined {
  84. if (!title) return undefined;
  85. return trendSummary ? `${title}|${trendSummary}` : title;
  86. }
  87. export async function runPipeline(
  88. input: PipelineInput,
  89. config: PipelineConfig,
  90. callbacks?: PipelineCallbacks
  91. ): Promise<PipelineJob> {
  92. const jobId = randomUUID();
  93. const log = createLogger("pipeline");
  94. const t0 = Date.now();
  95. const workDir = join(config.output.dir, "tmp", jobId);
  96. await mkdir(workDir, { recursive: true });
  97. // Opportunistic cache sweep: prune outputs older than retentionDays.
  98. // Runs on every job (service jobs are low-frequency); never throws.
  99. if (config.output.retentionDays && config.output.retentionDays > 0) {
  100. const cleaned = await cleanupExpiredOutput(config.output.dir, config.output.retentionDays);
  101. log.debug(
  102. `cleanup scanned=${cleaned.scanned} removed=${cleaned.removed} freed=${Math.round(cleaned.bytesFreed / 1024 / 1024)}MB`
  103. );
  104. }
  105. const platforms = input.platforms;
  106. const job: PipelineJob = {
  107. id: jobId,
  108. input,
  109. status: "running",
  110. createdAt: Date.now(),
  111. updatedAt: Date.now(),
  112. };
  113. log.info(
  114. `job ${jobId} start template=${input.template} platforms=${platforms.join(",")} ` +
  115. `chars=${input.text.length} skipLlm=${!!config.skipLlm} skipTts=${!!config.skipTts}`
  116. );
  117. try {
  118. // Stage 1: Parse (shared across all platforms)
  119. callbacks?.onStageStart?.("parse");
  120. const tParse = Date.now();
  121. const parsed = await parseText(input.text, input.template, {
  122. llm: config.llm,
  123. skipLlm: config.skipLlm,
  124. source: input.source,
  125. });
  126. job.parsed = parsed;
  127. log.info(`parse ok scenes=${parsed.scenes?.length ?? 0} (${Date.now() - tParse}ms)`);
  128. callbacks?.onStageComplete?.("parse");
  129. // Stage 2: TTS (shared across all platforms)
  130. callbacks?.onStageStart?.("tts");
  131. const tTts = Date.now();
  132. const tts = await generateTTS(parsed, workDir, {
  133. provider: config.tts.provider,
  134. voiceId: config.tts.voiceId || input.voiceId,
  135. model: config.tts.model,
  136. format: config.tts.format,
  137. speed: config.tts.speed,
  138. skip: config.skipTts,
  139. alignment: config.alignment,
  140. });
  141. job.tts = tts;
  142. log.info(
  143. `tts ok provider=${config.tts.provider} scenes=${tts.scenes?.length ?? 0} ` +
  144. `duration=${(tts.totalDurationSeconds ?? 0).toFixed(1)}s (${Date.now() - tTts}ms)`
  145. );
  146. callbacks?.onStageComplete?.("tts");
  147. // Stages 3-6: Per-platform loop
  148. const allExportFiles: ExportFile[] = [];
  149. job.composed = {};
  150. for (const platform of platforms) {
  151. // Stage 3: Assets
  152. callbacks?.onStageStart?.(`assets:${platform}`);
  153. const assets = await resolveAssets(
  154. parsed, workDir, config.assets.root, config.assets.inputDir,
  155. { template: input.template, aspect: PLATFORM_PRESETS[platform].aspect }
  156. );
  157. callbacks?.onStageComplete?.(`assets:${platform}`);
  158. // Stage 4: Compose
  159. callbacks?.onStageStart?.(`compose:${platform}`);
  160. const composed = composeProject(parsed, tts, assets, platform, input.template, config.branding.channelName);
  161. job.composed[platform] = composed;
  162. callbacks?.onStageComplete?.(`compose:${platform}`);
  163. // Stage 5: Render
  164. callbacks?.onStageStart?.(`render:${platform}`);
  165. const renderOutput = join(workDir, `render-${platform}.mp4`);
  166. const tRender = Date.now();
  167. await renderVideo(composed, renderOutput, config.templates.entryPoint, config.assets.root);
  168. log.info(`render ${platform} ok -> ${renderOutput} (${Date.now() - tRender}ms)`);
  169. callbacks?.onStageComplete?.(`render:${platform}`);
  170. // Stage 6: Export
  171. callbacks?.onStageStart?.(`export:${platform}`);
  172. const exported = await exportVideo(
  173. renderOutput,
  174. composed,
  175. config.output.dir,
  176. jobId,
  177. config.publishMeta?.[platform]?.[input.template]
  178. );
  179. const expFile = exported.files[0];
  180. log.info(
  181. `export ${platform} -> ${expFile?.filePath} ` +
  182. `(${((expFile?.fileSizeBytes ?? 0) / 1024 / 1024).toFixed(1)}MB)`
  183. );
  184. allExportFiles.push(...exported.files);
  185. callbacks?.onStageComplete?.(`export:${platform}`);
  186. }
  187. job.exported = { jobId, createdAt: Date.now(), files: allExportFiles };
  188. job.status = "completed";
  189. // Stage 7: Publish — upload to OSS + notify Feishu. Opt-in via config.
  190. if (!config.skipPublish && config.publish) {
  191. log.info(
  192. `publish oss=${config.publish.oss ? "on" : "off"} feishu=${config.publish.feishu ? "on" : "off"}`
  193. );
  194. callbacks?.onStageStart?.("publish");
  195. const publishCtx = {
  196. jobId,
  197. template: input.template,
  198. platforms,
  199. titleSuggestion: buildTitleSuggestion(
  200. parsed.title,
  201. parsed.scenes[0]?.trendSummary
  202. ),
  203. };
  204. try {
  205. const outcome = await runPublish(allExportFiles, config.publish, publishCtx);
  206. const urlByPath = new Map(outcome.uploaded.map((u) => [u.filePath, u.ossUrl]));
  207. job.exported = {
  208. ...job.exported,
  209. files: allExportFiles.map((f) => ({
  210. ...f,
  211. ossUrl: urlByPath.get(f.filePath),
  212. })),
  213. };
  214. if (!outcome.ok && outcome.error) {
  215. job.publishError = outcome.error;
  216. log.error(`publish failed: ${outcome.error}`);
  217. callbacks?.onError?.("publish", new Error(outcome.error));
  218. }
  219. callbacks?.onStageComplete?.("publish");
  220. } catch (err) {
  221. // Publish must never mask a successful render.
  222. const msg = err instanceof Error ? err.message : String(err);
  223. job.publishError = msg;
  224. log.error(`publish error: ${msg}`);
  225. callbacks?.onError?.("publish", err instanceof Error ? err : new Error(msg));
  226. }
  227. }
  228. } catch (err) {
  229. job.status = "failed";
  230. job.error = err instanceof Error ? err.message : String(err);
  231. log.error(`job ${jobId} failed: ${job.error}`);
  232. callbacks?.onError?.("pipeline", err instanceof Error ? err : new Error(String(err)));
  233. // Render failed before any file existed — push failure info to Feishu.
  234. if (!config.skipPublish && config.publish) {
  235. await notifyGenerationFailure(
  236. config.publish,
  237. {
  238. jobId,
  239. template: input.template,
  240. platforms,
  241. titleSuggestion: buildTitleSuggestion(
  242. job.parsed?.title,
  243. job.parsed?.scenes?.[0]?.trendSummary
  244. ),
  245. },
  246. job.error ?? "unknown error"
  247. ).catch(() => {});
  248. }
  249. }
  250. const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
  251. log.info(
  252. `job ${jobId} ${job.status} in ${elapsed}s` +
  253. (job.publishError ? ` publishError=${job.publishError.slice(0, 160)}` : "")
  254. );
  255. job.updatedAt = Date.now();
  256. return job;
  257. }