pipeline.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  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 { parseText } from "./stages/parse.js";
  11. import { generateTTS } from "./stages/tts.js";
  12. import { resolveAssets } from "./stages/assets.js";
  13. import { composeProject } from "./stages/compose.js";
  14. import { renderVideo } from "./stages/render.js";
  15. import { exportVideo } from "./stages/export.js";
  16. import { cleanupExpiredOutput } from "./cleanup.js";
  17. import {
  18. runPublish,
  19. notifyGenerationFailure,
  20. type PublishConfig,
  21. } from "./publish/index.js";
  22. export interface PipelineConfig {
  23. branding: {
  24. channelName: string;
  25. };
  26. llm: {
  27. baseURL?: string;
  28. apiKey?: string;
  29. model: string;
  30. };
  31. tts: {
  32. provider: string;
  33. voiceId?: string;
  34. model?: string;
  35. format?: "mp3" | "wav" | "pcm";
  36. speed?: number;
  37. };
  38. alignment?: {
  39. provider: "whisper" | "native";
  40. whisperModel?: string;
  41. language?: string;
  42. };
  43. output: {
  44. dir: string;
  45. /** Auto-clean cached outputs older than this many days (0 disables). */
  46. retentionDays?: number;
  47. };
  48. /** OSS upload + Feishu notification, resolved by resolvePublishConfig. */
  49. publish?: PublishConfig;
  50. /** Skip the publish stage entirely (e.g. local debugging). */
  51. skipPublish?: boolean;
  52. assets: {
  53. root: string;
  54. inputDir: string;
  55. };
  56. templates: {
  57. entryPoint: string;
  58. };
  59. skipTts?: boolean;
  60. skipLlm?: boolean;
  61. }
  62. export interface PipelineCallbacks {
  63. onStageStart?: (stage: string) => void;
  64. onStageComplete?: (stage: string) => void;
  65. onError?: (stage: string, error: Error) => void;
  66. }
  67. export async function runPipeline(
  68. input: PipelineInput,
  69. config: PipelineConfig,
  70. callbacks?: PipelineCallbacks
  71. ): Promise<PipelineJob> {
  72. const jobId = randomUUID();
  73. const workDir = join(config.output.dir, "tmp", jobId);
  74. await mkdir(workDir, { recursive: true });
  75. // Opportunistic cache sweep: prune outputs older than retentionDays.
  76. // Runs on every job (service jobs are low-frequency); never throws.
  77. if (config.output.retentionDays && config.output.retentionDays > 0) {
  78. await cleanupExpiredOutput(config.output.dir, config.output.retentionDays);
  79. }
  80. const platforms = input.platforms;
  81. const job: PipelineJob = {
  82. id: jobId,
  83. input,
  84. status: "running",
  85. createdAt: Date.now(),
  86. updatedAt: Date.now(),
  87. };
  88. try {
  89. // Stage 1: Parse (shared across all platforms)
  90. callbacks?.onStageStart?.("parse");
  91. const parsed = await parseText(input.text, input.template, {
  92. llm: config.llm,
  93. skipLlm: config.skipLlm,
  94. source: input.source,
  95. });
  96. job.parsed = parsed;
  97. callbacks?.onStageComplete?.("parse");
  98. // Stage 2: TTS (shared across all platforms)
  99. callbacks?.onStageStart?.("tts");
  100. const tts = await generateTTS(parsed, workDir, {
  101. provider: config.tts.provider,
  102. voiceId: config.tts.voiceId || input.voiceId,
  103. model: config.tts.model,
  104. format: config.tts.format,
  105. speed: config.tts.speed,
  106. skip: config.skipTts,
  107. alignment: config.alignment,
  108. });
  109. job.tts = tts;
  110. callbacks?.onStageComplete?.("tts");
  111. // Stages 3-6: Per-platform loop
  112. const allExportFiles: ExportFile[] = [];
  113. job.composed = {};
  114. for (const platform of platforms) {
  115. // Stage 3: Assets
  116. callbacks?.onStageStart?.(`assets:${platform}`);
  117. const assets = await resolveAssets(
  118. parsed, workDir, config.assets.root, config.assets.inputDir,
  119. { template: input.template, aspect: PLATFORM_PRESETS[platform].aspect }
  120. );
  121. callbacks?.onStageComplete?.(`assets:${platform}`);
  122. // Stage 4: Compose
  123. callbacks?.onStageStart?.(`compose:${platform}`);
  124. const composed = composeProject(parsed, tts, assets, platform, input.template, config.branding.channelName);
  125. job.composed[platform] = composed;
  126. callbacks?.onStageComplete?.(`compose:${platform}`);
  127. // Stage 5: Render
  128. callbacks?.onStageStart?.(`render:${platform}`);
  129. const renderOutput = join(workDir, `render-${platform}.mp4`);
  130. await renderVideo(composed, renderOutput, config.templates.entryPoint, config.assets.root);
  131. callbacks?.onStageComplete?.(`render:${platform}`);
  132. // Stage 6: Export
  133. callbacks?.onStageStart?.(`export:${platform}`);
  134. const exported = await exportVideo(
  135. renderOutput,
  136. composed,
  137. config.output.dir,
  138. jobId
  139. );
  140. allExportFiles.push(...exported.files);
  141. callbacks?.onStageComplete?.(`export:${platform}`);
  142. }
  143. job.exported = { jobId, createdAt: Date.now(), files: allExportFiles };
  144. job.status = "completed";
  145. // Stage 7: Publish — upload to OSS + notify Feishu. Opt-in via config.
  146. if (!config.skipPublish && config.publish) {
  147. callbacks?.onStageStart?.("publish");
  148. const publishCtx = {
  149. jobId,
  150. template: input.template,
  151. platforms,
  152. };
  153. try {
  154. const outcome = await runPublish(allExportFiles, config.publish, publishCtx);
  155. const urlByPath = new Map(outcome.uploaded.map((u) => [u.filePath, u.ossUrl]));
  156. job.exported = {
  157. ...job.exported,
  158. files: allExportFiles.map((f) => ({
  159. ...f,
  160. ossUrl: urlByPath.get(f.filePath),
  161. })),
  162. };
  163. if (!outcome.ok && outcome.error) {
  164. job.publishError = outcome.error;
  165. callbacks?.onError?.("publish", new Error(outcome.error));
  166. }
  167. callbacks?.onStageComplete?.("publish");
  168. } catch (err) {
  169. // Publish must never mask a successful render.
  170. const msg = err instanceof Error ? err.message : String(err);
  171. job.publishError = msg;
  172. callbacks?.onError?.("publish", err instanceof Error ? err : new Error(msg));
  173. }
  174. }
  175. } catch (err) {
  176. job.status = "failed";
  177. job.error = err instanceof Error ? err.message : String(err);
  178. callbacks?.onError?.("pipeline", err instanceof Error ? err : new Error(String(err)));
  179. // Render failed before any file existed — push failure info to Feishu.
  180. if (!config.skipPublish && config.publish) {
  181. await notifyGenerationFailure(
  182. config.publish,
  183. { jobId, template: input.template, platforms },
  184. job.error ?? "unknown error"
  185. ).catch(() => {});
  186. }
  187. }
  188. job.updatedAt = Date.now();
  189. return job;
  190. }