topic-to-video-batch-runner.service.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import { Injectable } from '@angular/core';
  2. import { firstValueFrom } from 'rxjs';
  3. import { BatchJob, BatchJobItem, BatchJobRunResult } from '../models/batch-job.model';
  4. import { BatchItemRunner } from './batch-job.service';
  5. import { GenerationTaskService } from './generation-task.service';
  6. import { JimengService, JimengVideoQuality } from './jimeng.service';
  7. import { PipelineSessionService } from './pipeline-session.service';
  8. import { VideoDurationService } from './video-duration.service';
  9. @Injectable({ providedIn: 'root' })
  10. export class TopicToVideoBatchRunnerService {
  11. constructor(
  12. private generationTasks: GenerationTaskService,
  13. private jimeng: JimengService,
  14. private sessions: PipelineSessionService,
  15. private videoDuration: VideoDurationService,
  16. ) {}
  17. async run(item: BatchJobItem, job: BatchJob, context?: Parameters<BatchItemRunner>[2]): Promise<BatchJobRunResult> {
  18. const input = item.input || {};
  19. const templateConfig = this.safeObject(input['templateConfig']);
  20. const topic = String(input['topic'] || item.title || '').trim();
  21. const quality = this.normalizeQuality(templateConfig['clipQuality']);
  22. const durationSeconds = Math.max(3, Math.min(15, Math.round(Number(templateConfig['clipDurationSeconds']) || this.videoDuration.framesToSeconds(Number(templateConfig['clipFrames'] || 121)))));
  23. const frames = this.videoDuration.secondsToFrames(durationSeconds);
  24. const aspectRatio = this.normalizeAspect(templateConfig['aspect']);
  25. const task = this.generationTasks.create({
  26. title: topic || '批量主题生视频',
  27. pipelineId: job.pipelineId,
  28. operation: 'batch-topic-to-video-real',
  29. estimatedCredits: 0,
  30. steps: [
  31. { id: 'prepare', label: '整理主题' },
  32. { id: 'submit', label: '提交真实生成' },
  33. { id: 'external', label: '等待外部结果' },
  34. { id: 'archive', label: '归档结果' },
  35. ],
  36. snapshot: {
  37. source: 'batch-production',
  38. batchJobId: job.id,
  39. batchItemId: item.id,
  40. topicId: item.topicId,
  41. topic,
  42. templateId: input['templateId'] || job.templateId || '',
  43. templateConfig,
  44. billing: 'disabled',
  45. },
  46. });
  47. this.generationTasks.markRunning(task.id, 'submit', 8);
  48. const prompt = this.buildPrompt(topic, templateConfig);
  49. const result = await firstValueFrom(this.jimeng.remixVideo(prompt, {
  50. method: '1',
  51. frames,
  52. aspectRatio,
  53. quality,
  54. }, (status, progress, meta) => {
  55. if (meta?.['workId']) {
  56. const workId = String(meta['workId']);
  57. this.generationTasks.markWaitingExternal(task.id, { workId }, 'external', progress);
  58. context?.markWaitingExternal({ workId }, 'external');
  59. } else {
  60. this.generationTasks.markRunning(task.id, progress >= 90 ? 'archive' : 'external', progress);
  61. context?.markRunning(progress >= 90 ? 'archive' : 'external');
  62. }
  63. }));
  64. const draft = await this.sessions.createArchivedDraft({
  65. pipelineId: job.pipelineId,
  66. title: topic || '批量主题生视频',
  67. resultUrl: result.videoUrl,
  68. status: 'completed',
  69. artifactTitle: '批量成片',
  70. artifactExtras: {
  71. source: 'batch-production',
  72. batchJobId: job.id,
  73. batchItemId: item.id,
  74. generationTaskId: task.id,
  75. workId: result.workId,
  76. quality,
  77. frames,
  78. durationSeconds,
  79. },
  80. snapshot: {
  81. source: 'batch-production',
  82. batchJobId: job.id,
  83. batchItemId: item.id,
  84. generationTaskId: task.id,
  85. workId: result.workId,
  86. topicId: item.topicId,
  87. topic,
  88. templateId: input['templateId'] || job.templateId || '',
  89. templateConfig,
  90. billing: 'disabled',
  91. },
  92. });
  93. this.generationTasks.markWaitingExternal(task.id, { workId: result.workId, draftId: draft.id }, 'archive', 96);
  94. this.generationTasks.markCompleted(task.id, result.videoUrl);
  95. return {
  96. generationTaskId: task.id,
  97. resultUrl: result.videoUrl,
  98. externalTaskIds: { workId: result.workId, draftId: draft.id },
  99. };
  100. }
  101. private buildPrompt(topic: string, config: Record<string, any>): string {
  102. const style = String(config['style'] || 'cinematic').trim();
  103. const scenes = Number(config['nScenes'] || 5);
  104. const mediaMode = String(config['mediaMode'] || 'video');
  105. const bible = this.safeObject(config['visualBible']);
  106. return [
  107. `主题:${topic}`,
  108. `生成一个完整的中文短视频画面,竖屏优先,结构约 ${scenes} 个镜头。`,
  109. `视觉风格:${style},模式:${mediaMode}。`,
  110. bible['subject'] ? `固定主体:${bible['subject']}` : '',
  111. bible['product'] ? `产品/卖点:${bible['product']}` : '',
  112. bible['location'] ? `场景:${bible['location']}` : '',
  113. bible['palette'] ? `色彩:${bible['palette']}` : '',
  114. bible['lighting'] ? `光线:${bible['lighting']}` : '',
  115. '镜头需要有明确开头、推进和收束,画面连贯,避免字幕、水印、乱码文字和多余 logo。',
  116. ].filter(Boolean).join('\n');
  117. }
  118. private safeObject(value: any): Record<string, any> {
  119. return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
  120. }
  121. private normalizeQuality(value: any): JimengVideoQuality {
  122. return value === '1080p' ? '1080p' : value === 'pro' ? 'pro' : '720p';
  123. }
  124. private normalizeAspect(value: any): '16:9' | '4:3' | '1:1' | '3:4' | '9:16' | '21:9' {
  125. return ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'].includes(value) ? value : '9:16';
  126. }
  127. }