Files
NianAIGC/lib/prompt/assembler.ts
2026-05-29 10:26:02 +08:00

250 lines
9.8 KiB
TypeScript

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();
}