reference-video-prompt-planner.service.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import { Injectable } from '@angular/core';
  2. import { Observable, from, of } from 'rxjs';
  3. import { catchError, map, switchMap } from 'rxjs/operators';
  4. import { JimengAspectRatio } from './jimeng.service';
  5. import { LlmService } from './llm.service';
  6. export interface ReferenceImageBrief {
  7. mainSubject: string;
  8. identityFeatures: string[];
  9. visualStyle: string;
  10. colorsAndLighting: string;
  11. composition: string;
  12. objects: string[];
  13. doNotChange: string[];
  14. }
  15. export interface ReferenceVideoPlanQualityHints {
  16. composition: string;
  17. style: string;
  18. lighting: string;
  19. aspectRatioSuggestion: JimengAspectRatio;
  20. }
  21. export interface ReferenceVideoPromptPlan {
  22. intentSummary: string;
  23. referenceKeep: string[];
  24. imagePrompt: string;
  25. videoPrompt: string;
  26. negativePrompt: string;
  27. qualityHints: ReferenceVideoPlanQualityHints;
  28. riskWarnings: string[];
  29. imageBrief: ReferenceImageBrief;
  30. source: 'llm' | 'fallback';
  31. }
  32. export interface ReferenceVideoPromptPlanInput {
  33. referenceImageUrl: string;
  34. userPrompt: string;
  35. aspect: JimengAspectRatio;
  36. }
  37. const ASPECTS: JimengAspectRatio[] = ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'];
  38. @Injectable({ providedIn: 'root' })
  39. export class ReferenceVideoPromptPlannerService {
  40. constructor(private llm: LlmService) {}
  41. plan(input: ReferenceVideoPromptPlanInput): Observable<ReferenceVideoPromptPlan> {
  42. const referenceImageUrl = String(input.referenceImageUrl || '').trim();
  43. const userPrompt = String(input.userPrompt || '').trim();
  44. if (!referenceImageUrl) {
  45. throw new Error('缺少参考图,无法规划参考图生成视频');
  46. }
  47. if (!userPrompt) {
  48. throw new Error('缺少提示词,无法规划参考图生成视频');
  49. }
  50. return from(this.llm.urlToBase64(referenceImageUrl)).pipe(
  51. switchMap(({ base64, mimeType }) => this.llm.analyzeImage(
  52. base64,
  53. mimeType,
  54. this.imageAnalysisPrompt(),
  55. {
  56. model: 'gemini-2.5-flash',
  57. generationConfig: { temperature: 0.25, maxOutputTokens: 1600 },
  58. },
  59. )),
  60. map((text) => this.parseImageBrief(text)),
  61. switchMap((brief) => this.llm.askWithSystemDetailed(
  62. this.plannerSystemPrompt(),
  63. this.plannerUserPrompt(brief, userPrompt, input.aspect),
  64. { model: 'gpt-4o-mini', temperature: 0.35, max_tokens: 1800 },
  65. ).pipe(
  66. map((result) => this.parsePlan(result.content, brief, input.aspect)),
  67. catchError((error) => of(this.fallbackPlan(brief, userPrompt, input.aspect, error))),
  68. )),
  69. catchError((error) => {
  70. const emptyBrief = this.emptyBrief();
  71. return of(this.fallbackPlan(emptyBrief, userPrompt, input.aspect, error));
  72. }),
  73. );
  74. }
  75. private imageAnalysisPrompt(): string {
  76. return `请分析这张参考图,输出严格的 JSON,不要 markdown,不要解释。
  77. {
  78. "mainSubject": "主体是什么,30字内",
  79. "identityFeatures": ["必须保留的主体特征,每条20字内"],
  80. "visualStyle": "画风/摄影风格/质感,30字内",
  81. "colorsAndLighting": "色彩和光线,40字内",
  82. "composition": "构图、景别、视角,40字内",
  83. "objects": ["重要物体"],
  84. "doNotChange": ["不应改变的内容"]
  85. }
  86. 只描述图中可见内容,不要推测品牌、身份或不可见信息。`;
  87. }
  88. private plannerSystemPrompt(): string {
  89. return `你是“参考图生视频”的提示词规划器。
  90. 任务:根据参考图摘要和用户需求,输出严格 JSON,用于两段式生成。
  91. 第一段 imagePrompt 给图片生成模型:参考图只是视觉参考,生成一张新的、适合作为视频首帧的图片。
  92. 第二段 videoPrompt 给图生视频模型:基于新首帧生成自然动态视频。
  93. 规则:
  94. 1. imagePrompt 必须包含“以参考图为视觉参考”和“不要直接复制原图构图”。
  95. 2. imagePrompt 必须说明保留参考图核心主体、关键外观、材质、风格或构图关系。
  96. 3. videoPrompt 只描述主体动作、环境变化、镜头运动和一致性约束。
  97. 4. videoPrompt 不要引入 imagePrompt 没有建立的主体、场景或风格。
  98. 5. negativePrompt 写成一句中文,包含身份/主体/产品形态/水印文字限制。
  99. 6. 不要输出 markdown,不要解释,只输出 JSON。
  100. 7. 输出字段必须完整。
  101. JSON 结构:
  102. {
  103. "intentSummary": "",
  104. "referenceKeep": [],
  105. "imagePrompt": "",
  106. "videoPrompt": "",
  107. "negativePrompt": "",
  108. "qualityHints": {
  109. "composition": "",
  110. "style": "",
  111. "lighting": "",
  112. "aspectRatioSuggestion": "16:9"
  113. },
  114. "riskWarnings": []
  115. }`;
  116. }
  117. private plannerUserPrompt(brief: ReferenceImageBrief, userPrompt: string, aspect: JimengAspectRatio): string {
  118. return [
  119. `【参考图摘要】${JSON.stringify(brief)}`,
  120. `【用户需求】${userPrompt}`,
  121. `【当前画面比例】${aspect}`,
  122. '请输出严格 JSON。',
  123. ].join('\n');
  124. }
  125. private parseImageBrief(text: string): ReferenceImageBrief {
  126. const parsed = this.parseJsonObject(text);
  127. return {
  128. mainSubject: this.clean(parsed['mainSubject']),
  129. identityFeatures: this.cleanList(parsed['identityFeatures']).slice(0, 8),
  130. visualStyle: this.clean(parsed['visualStyle']),
  131. colorsAndLighting: this.clean(parsed['colorsAndLighting']),
  132. composition: this.clean(parsed['composition']),
  133. objects: this.cleanList(parsed['objects']).slice(0, 8),
  134. doNotChange: this.cleanList(parsed['doNotChange']).slice(0, 8),
  135. };
  136. }
  137. private parsePlan(text: string, brief: ReferenceImageBrief, fallbackAspect: JimengAspectRatio): ReferenceVideoPromptPlan {
  138. const parsed = this.parseJsonObject(text);
  139. const hints = parsed['qualityHints'] && typeof parsed['qualityHints'] === 'object'
  140. ? parsed['qualityHints'] as Record<string, unknown>
  141. : {};
  142. const plan: ReferenceVideoPromptPlan = {
  143. intentSummary: this.clean(parsed['intentSummary']) || '参考图生成视频',
  144. referenceKeep: this.cleanList(parsed['referenceKeep']).slice(0, 8),
  145. imagePrompt: this.clean(parsed['imagePrompt']),
  146. videoPrompt: this.clean(parsed['videoPrompt']),
  147. negativePrompt: this.clean(parsed['negativePrompt']),
  148. qualityHints: {
  149. composition: this.clean(hints['composition']),
  150. style: this.clean(hints['style']),
  151. lighting: this.clean(hints['lighting']),
  152. aspectRatioSuggestion: this.normalizeAspect(hints['aspectRatioSuggestion'], fallbackAspect),
  153. },
  154. riskWarnings: this.cleanList(parsed['riskWarnings']).slice(0, 6),
  155. imageBrief: brief,
  156. source: 'llm',
  157. };
  158. if (!plan.imagePrompt || !plan.videoPrompt) {
  159. throw new Error('LLM 规划缺少 imagePrompt 或 videoPrompt');
  160. }
  161. if (!plan.imagePrompt.includes('参考图')) {
  162. plan.imagePrompt = `以参考图为视觉参考,${plan.imagePrompt}`;
  163. }
  164. if (!plan.imagePrompt.includes('不要直接复制原图构图')) {
  165. plan.imagePrompt = `${plan.imagePrompt},但不要直接复制原图构图。`;
  166. }
  167. return plan;
  168. }
  169. private fallbackPlan(
  170. brief: ReferenceImageBrief,
  171. userPrompt: string,
  172. aspect: JimengAspectRatio,
  173. error: unknown,
  174. ): ReferenceVideoPromptPlan {
  175. const keep = [
  176. brief.mainSubject ? `保留${brief.mainSubject}` : '',
  177. ...brief.identityFeatures.map((item) => `保留${item}`),
  178. ...brief.doNotChange.map((item) => `不要改变${item}`),
  179. ].filter(Boolean).slice(0, 8);
  180. const keepText = keep.length
  181. ? keep.join(',')
  182. : '保留参考图中的核心主体、主要外观、色彩关系和风格质感';
  183. const styleText = [brief.visualStyle, brief.colorsAndLighting, brief.composition].filter(Boolean).join(',');
  184. const message = error instanceof Error ? error.message : String(error || 'LLM 规划失败');
  185. return {
  186. intentSummary: userPrompt.slice(0, 40) || '参考图生成视频',
  187. referenceKeep: keep,
  188. imagePrompt: [
  189. '以参考图为视觉参考',
  190. keepText,
  191. '但不要直接复制原图构图',
  192. `根据用户需求生成一张清晰、主体完整、适合作为视频首帧的高清图片:${userPrompt}`,
  193. styleText ? `参考视觉风格:${styleText}` : '',
  194. '画面完整,无文字水印,无畸形结构。',
  195. ].filter(Boolean).join(','),
  196. videoPrompt: [
  197. '基于首帧画面生成自然动态视频',
  198. userPrompt,
  199. '保持主体外观、场景风格和光线一致,动作自然流畅,不要改变主体身份,不要新增无关物体。',
  200. ].filter(Boolean).join('。'),
  201. negativePrompt: '不要改变主体身份、产品形态、关键结构,不要出现水印、字幕、乱码文字或无关物体。',
  202. qualityHints: {
  203. composition: brief.composition || '主体完整,适合作为视频首帧',
  204. style: brief.visualStyle || '高质量视觉风格',
  205. lighting: brief.colorsAndLighting || '自然统一的光线',
  206. aspectRatioSuggestion: aspect,
  207. },
  208. riskWarnings: [`已使用保守兜底规划:${message}`],
  209. imageBrief: brief,
  210. source: 'fallback',
  211. };
  212. }
  213. private parseJsonObject(text: string): Record<string, unknown> {
  214. const raw = String(text || '').trim();
  215. const cleaned = raw
  216. .replace(/```(?:json)?/gi, '')
  217. .replace(/```/g, '')
  218. .trim();
  219. const firstBrace = cleaned.indexOf('{');
  220. const lastBrace = cleaned.lastIndexOf('}');
  221. if (firstBrace < 0 || lastBrace <= firstBrace) {
  222. throw new Error(`LLM 未返回 JSON 对象:${raw.slice(0, 200)}`);
  223. }
  224. const parsed = JSON.parse(cleaned.slice(firstBrace, lastBrace + 1));
  225. if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
  226. throw new Error('LLM 返回的 JSON 不是对象');
  227. }
  228. return parsed as Record<string, unknown>;
  229. }
  230. private emptyBrief(): ReferenceImageBrief {
  231. return {
  232. mainSubject: '',
  233. identityFeatures: [],
  234. visualStyle: '',
  235. colorsAndLighting: '',
  236. composition: '',
  237. objects: [],
  238. doNotChange: [],
  239. };
  240. }
  241. private clean(value: unknown): string {
  242. return String(value ?? '').replace(/\s+/g, ' ').trim();
  243. }
  244. private cleanList(value: unknown): string[] {
  245. if (!Array.isArray(value)) return [];
  246. return value.map((item) => this.clean(item)).filter(Boolean);
  247. }
  248. private normalizeAspect(value: unknown, fallback: JimengAspectRatio): JimengAspectRatio {
  249. const raw = this.clean(value) as JimengAspectRatio;
  250. return ASPECTS.includes(raw) ? raw : fallback;
  251. }
  252. }