250 lines
9.8 KiB
TypeScript
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();
|
|
}
|