pipeline.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  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 } 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. /** Skip the publish stage entirely (e.g. local debugging). */
  52. skipPublish?: boolean;
  53. assets: {
  54. root: string;
  55. inputDir: string;
  56. };
  57. templates: {
  58. entryPoint: string;
  59. };
  60. skipTts?: boolean;
  61. skipLlm?: boolean;
  62. }
  63. export interface PipelineCallbacks {
  64. onStageStart?: (stage: string) => void;
  65. onStageComplete?: (stage: string) => void;
  66. onError?: (stage: string, error: Error) => void;
  67. }
  68. export async function runPipeline(
  69. input: PipelineInput,
  70. config: PipelineConfig,
  71. callbacks?: PipelineCallbacks
  72. ): Promise<PipelineJob> {
  73. const jobId = randomUUID();
  74. const log = createLogger("pipeline");
  75. const t0 = Date.now();
  76. const workDir = join(config.output.dir, "tmp", jobId);
  77. await mkdir(workDir, { recursive: true });
  78. // Opportunistic cache sweep: prune outputs older than retentionDays.
  79. // Runs on every job (service jobs are low-frequency); never throws.
  80. if (config.output.retentionDays && config.output.retentionDays > 0) {
  81. const cleaned = await cleanupExpiredOutput(config.output.dir, config.output.retentionDays);
  82. log.debug(
  83. `cleanup scanned=${cleaned.scanned} removed=${cleaned.removed} freed=${Math.round(cleaned.bytesFreed / 1024 / 1024)}MB`
  84. );
  85. }
  86. const platforms = input.platforms;
  87. const job: PipelineJob = {
  88. id: jobId,
  89. input,
  90. status: "running",
  91. createdAt: Date.now(),
  92. updatedAt: Date.now(),
  93. };
  94. log.info(
  95. `job ${jobId} start template=${input.template} platforms=${platforms.join(",")} ` +
  96. `chars=${input.text.length} skipLlm=${!!config.skipLlm} skipTts=${!!config.skipTts}`
  97. );
  98. try {
  99. // Stage 1: Parse (shared across all platforms)
  100. callbacks?.onStageStart?.("parse");
  101. const tParse = Date.now();
  102. const parsed = await parseText(input.text, input.template, {
  103. llm: config.llm,
  104. skipLlm: config.skipLlm,
  105. source: input.source,
  106. });
  107. job.parsed = parsed;
  108. log.info(`parse ok scenes=${parsed.scenes?.length ?? 0} (${Date.now() - tParse}ms)`);
  109. callbacks?.onStageComplete?.("parse");
  110. // Stage 2: TTS (shared across all platforms)
  111. callbacks?.onStageStart?.("tts");
  112. const tTts = Date.now();
  113. const tts = await generateTTS(parsed, workDir, {
  114. provider: config.tts.provider,
  115. voiceId: config.tts.voiceId || input.voiceId,
  116. model: config.tts.model,
  117. format: config.tts.format,
  118. speed: config.tts.speed,
  119. skip: config.skipTts,
  120. alignment: config.alignment,
  121. });
  122. job.tts = tts;
  123. log.info(
  124. `tts ok provider=${config.tts.provider} scenes=${tts.scenes?.length ?? 0} ` +
  125. `duration=${(tts.totalDurationSeconds ?? 0).toFixed(1)}s (${Date.now() - tTts}ms)`
  126. );
  127. callbacks?.onStageComplete?.("tts");
  128. // Stages 3-6: Per-platform loop
  129. const allExportFiles: ExportFile[] = [];
  130. job.composed = {};
  131. for (const platform of platforms) {
  132. // Stage 3: Assets
  133. callbacks?.onStageStart?.(`assets:${platform}`);
  134. const assets = await resolveAssets(
  135. parsed, workDir, config.assets.root, config.assets.inputDir,
  136. { template: input.template, aspect: PLATFORM_PRESETS[platform].aspect }
  137. );
  138. callbacks?.onStageComplete?.(`assets:${platform}`);
  139. // Stage 4: Compose
  140. callbacks?.onStageStart?.(`compose:${platform}`);
  141. const composed = composeProject(parsed, tts, assets, platform, input.template, config.branding.channelName);
  142. job.composed[platform] = composed;
  143. callbacks?.onStageComplete?.(`compose:${platform}`);
  144. // Stage 5: Render
  145. callbacks?.onStageStart?.(`render:${platform}`);
  146. const renderOutput = join(workDir, `render-${platform}.mp4`);
  147. const tRender = Date.now();
  148. await renderVideo(composed, renderOutput, config.templates.entryPoint, config.assets.root);
  149. log.info(`render ${platform} ok -> ${renderOutput} (${Date.now() - tRender}ms)`);
  150. callbacks?.onStageComplete?.(`render:${platform}`);
  151. // Stage 6: Export
  152. callbacks?.onStageStart?.(`export:${platform}`);
  153. const exported = await exportVideo(
  154. renderOutput,
  155. composed,
  156. config.output.dir,
  157. jobId
  158. );
  159. const expFile = exported.files[0];
  160. log.info(
  161. `export ${platform} -> ${expFile?.filePath} ` +
  162. `(${((expFile?.fileSizeBytes ?? 0) / 1024 / 1024).toFixed(1)}MB)`
  163. );
  164. allExportFiles.push(...exported.files);
  165. callbacks?.onStageComplete?.(`export:${platform}`);
  166. }
  167. job.exported = { jobId, createdAt: Date.now(), files: allExportFiles };
  168. job.status = "completed";
  169. // Stage 7: Publish — upload to OSS + notify Feishu. Opt-in via config.
  170. if (!config.skipPublish && config.publish) {
  171. log.info(
  172. `publish oss=${config.publish.oss ? "on" : "off"} feishu=${config.publish.feishu ? "on" : "off"}`
  173. );
  174. callbacks?.onStageStart?.("publish");
  175. const publishCtx = {
  176. jobId,
  177. template: input.template,
  178. platforms,
  179. };
  180. try {
  181. const outcome = await runPublish(allExportFiles, config.publish, publishCtx);
  182. const urlByPath = new Map(outcome.uploaded.map((u) => [u.filePath, u.ossUrl]));
  183. job.exported = {
  184. ...job.exported,
  185. files: allExportFiles.map((f) => ({
  186. ...f,
  187. ossUrl: urlByPath.get(f.filePath),
  188. })),
  189. };
  190. if (!outcome.ok && outcome.error) {
  191. job.publishError = outcome.error;
  192. log.error(`publish failed: ${outcome.error}`);
  193. callbacks?.onError?.("publish", new Error(outcome.error));
  194. }
  195. callbacks?.onStageComplete?.("publish");
  196. } catch (err) {
  197. // Publish must never mask a successful render.
  198. const msg = err instanceof Error ? err.message : String(err);
  199. job.publishError = msg;
  200. log.error(`publish error: ${msg}`);
  201. callbacks?.onError?.("publish", err instanceof Error ? err : new Error(msg));
  202. }
  203. }
  204. } catch (err) {
  205. job.status = "failed";
  206. job.error = err instanceof Error ? err.message : String(err);
  207. log.error(`job ${jobId} failed: ${job.error}`);
  208. callbacks?.onError?.("pipeline", err instanceof Error ? err : new Error(String(err)));
  209. // Render failed before any file existed — push failure info to Feishu.
  210. if (!config.skipPublish && config.publish) {
  211. await notifyGenerationFailure(
  212. config.publish,
  213. { jobId, template: input.template, platforms },
  214. job.error ?? "unknown error"
  215. ).catch(() => {});
  216. }
  217. }
  218. const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
  219. log.info(
  220. `job ${jobId} ${job.status} in ${elapsed}s` +
  221. (job.publishError ? ` publishError=${job.publishError.slice(0, 160)}` : "")
  222. );
  223. job.updatedAt = Date.now();
  224. return job;
  225. }