import { getTemplateById, type VideoTemplate } from "@/lib/content/video-templates"; export type StoryboardScene = { id: string; title: string; visual: string; camera?: string; hostLine?: string; caption?: string; materialLabel?: string; }; export type PromptMaterial = { id?: string; url: string; type: "image" | "video" | "audio"; role?: string; label?: string; name?: string; }; export type PromptAssemblyInput = { mode: "video" | "image"; projectName?: string; audience?: string; offer?: string; brandLine?: string; selectedTemplateId?: string; manualPrompt?: string; storyboard?: StoryboardScene[]; materials?: PromptMaterial[]; imageGoal?: string; aspectRatio?: string; }; export type PromptAssemblyResult = { prompt: string; scenes: StoryboardScene[]; materials: PromptMaterial[]; warnings: string[]; blocked: boolean; requirements: { image: number; video: number; audio: number; }; }; export const defaultStoryboardScenes: StoryboardScene[] = [ { id: "scene-1", title: "开场画面", visual: "用上传素材建立项目的第一印象,主体清晰,氛围干净", camera: "中景或推进镜头", hostLine: "", caption: "项目亮相" }, { id: "scene-2", title: "场景氛围", visual: "展示空间、环境或使用场景,让观众理解项目所处的真实语境", camera: "横移或环绕", hostLine: "", caption: "场景氛围" }, { id: "scene-3", title: "核心内容", visual: "突出核心产品、服务、活动、空间或人物,呈现最重要的信息", camera: "主体特写", hostLine: "", caption: "核心内容" }, { id: "scene-4", title: "细节补充", visual: "补充质感、服务、流程、环境或亮点细节,增强可信度", camera: "细节切镜", hostLine: "", caption: "细节补充" }, { id: "scene-5", title: "收尾画面", visual: "用项目名称、品牌信息或完整画面收束,形成清楚的结束印象", camera: "定格或拉远", hostLine: "", caption: "项目记忆点" } ]; export function assemblePrompt(input: PromptAssemblyInput): PromptAssemblyResult { const scenes = input.storyboard?.length ? input.storyboard : defaultStoryboardScenes; const materials = normalizeMaterials(input.materials || []); const template = input.selectedTemplateId ? getTemplateById(input.selectedTemplateId) : undefined; const prompt = input.manualPrompt?.trim() ? input.manualPrompt.trim() : input.mode === "image" ? assembleImagePrompt(input, scenes, template) : assembleVideoPrompt(input, scenes, template); const requirements = extractMaterialRequirements(prompt); const warnings = validateMaterialCoverage(requirements, materials); return { prompt, scenes, materials, warnings, blocked: false, requirements }; } export function extractMaterialRequirements(prompt: string) { const requirements = { image: 0, video: 0, audio: 0 }; for (const match of prompt.matchAll(/@(参考视频|图片|图|视频|音频)(\d*)/g)) { const kind = match[1]; const index = match[2] ? Number(match[2]) : 1; if (kind === "图片" || kind === "图") requirements.image = Math.max(requirements.image, index); if (kind === "视频" || kind === "参考视频") requirements.video = Math.max(requirements.video, index); if (kind === "音频") requirements.audio = Math.max(requirements.audio, index); } return requirements; } export function normalizeMaterials(materials: PromptMaterial[]): PromptMaterial[] { const counters = { image: 0, video: 0, audio: 0 }; return materials .filter((material) => material.url) .map((material) => { const type = material.type || inferMaterialType(material.url); counters[type] += material.label ? 0 : 1; return { ...material, type, label: material.label || labelFor(type, counters[type]) }; }) .sort((a, b) => labelSortWeight(a.label) - labelSortWeight(b.label)); } export function materialContentForProvider(materials: PromptMaterial[], origin: string) { return normalizeMaterials(materials).map((material) => { const url = toAbsoluteUrl(material.url, origin); if (material.type === "video") { return { type: "video_url", video_url: { url }, role: "reference_video", label: material.label }; } if (material.type === "audio") { return { type: "audio_url", audio_url: { url }, role: "reference_audio", label: material.label }; } return { type: "image_url", image_url: { url }, role: "reference_image", label: material.label }; }); } function assembleVideoPrompt(input: PromptAssemblyInput, scenes: StoryboardScene[], template?: VideoTemplate): string { const projectName = input.projectName?.trim() || "当前项目"; const info = [ `项目名称:${projectName}`, input.audience?.trim() ? `目标人群:${input.audience.trim()}` : "", input.offer?.trim() ? `表达重点:${input.offer.trim()}` : "", input.brandLine?.trim() ? `补充说明:${input.brandLine.trim()}` : "" ].filter(Boolean).join("\n"); const templateText = template ? `参考模板:「${template.title}」。${template.referenceVideoUrl ? "参考@视频1的画面节奏、转场方式和整体质感。" : "提取模板的节奏、画面组织、转场和视觉气质。"}模板风格说明:${template.seedanceInstruction || template.prompt.slice(0, 180)}。只迁移模板风格和结构,最终内容以项目名称和上传素材为准。` : "参考风格:真实自然的营销宣传片,画面干净、节奏清楚、转场自然。"; const sceneText = scenes .map((scene, index) => { const label = scene.materialLabel || `@图片${index + 1}`; return `${index + 1}. ${scene.title}:素材参考=${label};内容方向=${scene.visual}${scene.camera ? `;镜头=${scene.camera}` : ""}${scene.hostLine ? `;口播=${scene.hostLine}` : ""}${scene.caption ? `;字幕=${scene.caption}` : ""}`; }) .join("\n"); return `通用营销宣传视频。 ${templateText} ${info} 内容结构: ${sceneText} 生成要求: - 以项目名称和@素材为准,不套用示例中的具体地点、人物、文案或品牌。 - 图片素材用于控制主体、场景、商品和分镜;视频素材用于控制节奏、转场、运镜或参考风格。 - 画面真实干净,主体清晰,转场自然,整体观感统一。 - 如生成字幕,只保留简短标题或重点信息,避免大段文字。 - 不额外添加与项目无关的信息。`; } function assembleImagePrompt(input: PromptAssemblyInput, scenes: StoryboardScene[], template?: VideoTemplate): string { const projectName = input.projectName?.trim() || "当前项目"; const imageGoal = input.imageGoal?.trim() || "生成可用于营销传播的主视觉图片"; const sceneText = scenes .slice(0, 4) .map((scene, index) => { const label = scene.materialLabel || `@图片${index + 1}`; return `${index + 1}. ${scene.title}:参考素材=${label};画面要点=${scene.visual}${scene.caption ? `;文字元素=${scene.caption}` : ""}`; }) .join("\n"); const templateText = template ? `可借鉴视频模板「${template.title}」的节奏感、镜头气质和画面组织,但输出为单张高质量营销图片。` : "整体风格真实、有设计感,适合品牌和社媒传播。"; return `营销图片生成。 项目名称:${projectName} 目标:${imageGoal} 目标人群:${input.audience?.trim() || "泛营销受众"} 表达重点:${input.offer?.trim() || "突出产品、服务或活动核心卖点"} 补充说明:${input.brandLine?.trim() || "保持干净、可信、可发布的视觉质感"} ${templateText} 素材引用: ${sceneText} 生成要求: - 严格参考提示词中的@图片素材,保持主体和关键信息一致。 - 可以使用视频素材作为节奏、镜头或氛围参考,但最终输出单张图片。 - 图片比例:${input.aspectRatio || "1:1"}。 - 文字内容少而准确,避免错别字和无关标语。 - 不额外添加与项目无关的信息。`; } function validateMaterialCoverage(requirements: { image: number; video: number; audio: number }, materials: PromptMaterial[]) { const available = materials.reduce((acc, material) => { acc[material.type] += 1; return acc; }, { image: 0, video: 0, audio: 0 }); const warnings = []; if (requirements.image > available.image) warnings.push(`提示词引用到 @图片${requirements.image},当前只绑定了 ${available.image} 张图片。`); if (requirements.video > available.video) warnings.push(`提示词引用到 @视频${requirements.video},当前只绑定了 ${available.video} 个视频。`); if (requirements.audio > available.audio) warnings.push(`提示词引用到 @音频${requirements.audio},当前只绑定了 ${available.audio} 个音频。`); return warnings; } function labelFor(type: PromptMaterial["type"], index: number): string { if (type === "video") return `@视频${index}`; if (type === "audio") return `@音频${index}`; return `@图片${index}`; } function labelSortWeight(label?: string): number { const match = label?.match(/^@(图片|图|视频|音频)(\d+)$/); if (!match) return 999; const base = match[1] === "图片" || match[1] === "图" ? 0 : match[1] === "视频" ? 100 : 200; return base + Number(match[2]); } function inferMaterialType(url: string): PromptMaterial["type"] { const lower = url.toLowerCase(); if (/\.(mp4|mov|webm)(\?|$)/.test(lower)) return "video"; if (/\.(mp3|wav|m4a|aac|flac)(\?|$)/.test(lower)) return "audio"; return "image"; } function toAbsoluteUrl(url: string, origin: string): string { if (/^https?:\/\//i.test(url) || url.startsWith("data:")) return url; return new URL(url, origin).toString(); }