Files
NianAIGC/backend/internal/prompt/assembler.go
T

246 lines
9.4 KiB
Go

// Package prompt implements the deterministic prompt-assembly contract used by
// browser and public generation requests.
package prompt
import (
"fmt"
"regexp"
"sort"
"strings"
)
type Scene struct {
ID string `json:"id"`
Title string `json:"title"`
Visual string `json:"visual"`
Camera string `json:"camera,omitempty"`
HostLine string `json:"hostLine,omitempty"`
Caption string `json:"caption,omitempty"`
MaterialLabel string `json:"materialLabel,omitempty"`
}
type Material struct {
ID string `json:"id,omitempty"`
URL string `json:"url"`
Type string `json:"type"`
Role string `json:"role,omitempty"`
Label string `json:"label,omitempty"`
Name string `json:"name,omitempty"`
}
type Input struct {
Mode string `json:"mode"`
ProjectName string `json:"projectName,omitempty"`
Audience string `json:"audience,omitempty"`
Offer string `json:"offer,omitempty"`
BrandLine string `json:"brandLine,omitempty"`
ManualPrompt string `json:"manualPrompt,omitempty"`
Storyboard []Scene `json:"storyboard,omitempty"`
Materials []Material `json:"materials,omitempty"`
ImageGoal string `json:"imageGoal,omitempty"`
AspectRatio string `json:"aspectRatio,omitempty"`
}
type Requirements struct {
Image int `json:"image"`
Video int `json:"video"`
Audio int `json:"audio"`
}
type Result struct {
Prompt string `json:"prompt"`
Scenes []Scene `json:"scenes"`
Materials []Material `json:"materials"`
Warnings []string `json:"warnings"`
Blocked bool `json:"blocked"`
Requirements Requirements `json:"requirements"`
}
var DefaultScenes = []Scene{
{ID: "scene-1", Title: "开场画面", Visual: "用上传素材建立项目的第一印象,主体清晰,氛围干净", Camera: "中景或推进镜头", Caption: "项目亮相"},
{ID: "scene-2", Title: "场景氛围", Visual: "展示空间、环境或使用场景,让观众理解项目所处的真实语境", Camera: "横移或环绕", Caption: "场景氛围"},
{ID: "scene-3", Title: "核心内容", Visual: "突出核心产品、服务、活动、空间或人物,呈现最重要的信息", Camera: "主体特写", Caption: "核心内容"},
{ID: "scene-4", Title: "细节补充", Visual: "补充质感、服务、流程、环境或亮点细节,增强可信度", Camera: "细节切镜", Caption: "细节补充"},
{ID: "scene-5", Title: "收尾画面", Visual: "用项目名称、品牌信息或完整画面收束,形成清楚的结束印象", Camera: "定格或拉远", Caption: "项目记忆点"},
}
func Assemble(input Input) Result {
scenes := input.Storyboard
if len(scenes) == 0 {
scenes = append([]Scene(nil), DefaultScenes...)
}
materials := NormalizeMaterials(input.Materials)
text := strings.TrimSpace(input.ManualPrompt)
if text == "" {
if input.Mode == "image" {
text = assembleImage(input, scenes)
} else {
text = assembleVideo(input, scenes)
}
}
requirements := ExtractRequirements(text)
warnings := []string{}
available := Requirements{}
for _, material := range materials {
switch material.Type {
case "video":
available.Video++
case "audio":
available.Audio++
default:
available.Image++
}
}
if requirements.Image > available.Image {
warnings = append(warnings, fmt.Sprintf("提示词引用到 @图片%d,当前只绑定了 %d 张图片。", requirements.Image, available.Image))
}
if requirements.Video > available.Video {
warnings = append(warnings, fmt.Sprintf("提示词引用到 @视频%d,当前只绑定了 %d 个视频。", requirements.Video, available.Video))
}
if requirements.Audio > available.Audio {
warnings = append(warnings, fmt.Sprintf("提示词引用到 @音频%d,当前只绑定了 %d 个音频。", requirements.Audio, available.Audio))
}
return Result{Prompt: text, Scenes: scenes, Materials: materials, Warnings: warnings, Blocked: false, Requirements: requirements}
}
func NormalizeMaterials(input []Material) []Material {
counters := map[string]int{"image": 0, "video": 0, "audio": 0}
out := make([]Material, 0, len(input))
for _, material := range input {
if strings.TrimSpace(material.URL) == "" {
continue
}
if material.Type != "image" && material.Type != "video" && material.Type != "audio" {
material.Type = inferType(material.URL)
}
if material.Label == "" {
counters[material.Type]++
material.Label = label(material.Type, counters[material.Type])
}
out = append(out, material)
}
sort.SliceStable(out, func(i, j int) bool { return labelWeight(out[i].Label) < labelWeight(out[j].Label) })
return out
}
var placeholderPattern = regexp.MustCompile(`@(参考视频|图片|图|视频|音频)([0-9]+)`)
func ExtractRequirements(text string) Requirements {
result := Requirements{}
for _, match := range placeholderPattern.FindAllStringSubmatch(text, -1) {
var index int
_, _ = fmt.Sscanf(match[2], "%d", &index)
if index < 1 {
continue
}
switch match[1] {
case "视频", "参考视频":
if index > result.Video {
result.Video = index
}
case "音频":
if index > result.Audio {
result.Audio = index
}
default:
if index > result.Image {
result.Image = index
}
}
}
return result
}
func assembleImage(input Input, scenes []Scene) string {
project := fallback(input.ProjectName, "当前项目")
goal := fallback(input.ImageGoal, "生成可用于营销传播的主视觉图片")
ratio := fallback(input.AspectRatio, "1:1")
lines := []string{}
for index, scene := range scenes {
if index >= 4 {
break
}
material := scene.MaterialLabel
if material == "" {
material = fmt.Sprintf("@图片%d", index+1)
}
suffix := ""
if scene.Caption != "" {
suffix = ";文字元素=" + scene.Caption
}
lines = append(lines, fmt.Sprintf("%d. %s:参考素材=%s;画面要点=%s%s", index+1, scene.Title, material, scene.Visual, suffix))
}
return fmt.Sprintf("营销图片生成。\n项目名称:%s\n目标:%s\n目标人群:%s\n表达重点:%s\n补充说明:%s\n整体风格真实、有设计感,适合品牌和社媒传播。\n素材引用:\n%s\n生成要求:\n- 严格参考提示词中的@图片素材,保持主体和关键信息一致。\n- 可以使用视频素材作为节奏、镜头或氛围参考,但最终输出单张图片。\n- 图片比例:%s。\n- 文字内容少而准确,避免错别字和无关标语。\n- 不额外添加与项目无关的信息。", project, fallback(input.ImageGoal, goal), fallback(input.Audience, "泛营销受众"), fallback(input.Offer, "突出产品、服务或活动核心卖点"), fallback(input.BrandLine, "保持干净、可信、可发布的视觉质感"), strings.Join(lines, "\n"), ratio)
}
func assembleVideo(input Input, scenes []Scene) string {
info := []string{"项目名称:" + fallback(input.ProjectName, "当前项目")}
if strings.TrimSpace(input.Audience) != "" {
info = append(info, "目标人群:"+strings.TrimSpace(input.Audience))
}
if strings.TrimSpace(input.Offer) != "" {
info = append(info, "表达重点:"+strings.TrimSpace(input.Offer))
}
if strings.TrimSpace(input.BrandLine) != "" {
info = append(info, "补充说明:"+strings.TrimSpace(input.BrandLine))
}
lines := []string{}
for index, scene := range scenes {
material := scene.MaterialLabel
if material == "" {
material = fmt.Sprintf("@图片%d", index+1)
}
suffix := ""
if scene.Camera != "" {
suffix += ";镜头=" + scene.Camera
}
if scene.HostLine != "" {
suffix += ";口播=" + scene.HostLine
}
if scene.Caption != "" {
suffix += ";字幕=" + scene.Caption
}
lines = append(lines, fmt.Sprintf("%d. %s:素材参考=%s;内容方向=%s%s", index+1, scene.Title, material, scene.Visual, suffix))
}
return fmt.Sprintf("通用营销宣传视频。\n参考风格:真实自然的营销宣传片,画面干净、节奏清楚、转场自然。\n%s\n内容结构:\n%s\n生成要求:\n- 以项目名称和@素材为准,不套用示例中的具体地点、人物、文案或品牌。\n- 图片素材用于控制主体、场景、商品和分镜;视频素材用于控制节奏、转场、运镜或参考风格。\n- 画面真实干净,主体清晰,转场自然,整体观感统一。\n- 如生成字幕,只保留简短标题或重点信息,避免大段文字。\n- 不额外添加与项目无关的信息。", strings.Join(info, "\n"), strings.Join(lines, "\n"))
}
func inferType(raw string) string {
lower := strings.ToLower(strings.Split(raw, "?")[0])
for _, suffix := range []string{".mp4", ".mov", ".webm"} {
if strings.HasSuffix(lower, suffix) {
return "video"
}
}
for _, suffix := range []string{".mp3", ".wav", ".m4a", ".aac", ".flac"} {
if strings.HasSuffix(lower, suffix) {
return "audio"
}
}
return "image"
}
func label(kind string, index int) string {
if kind == "video" {
return fmt.Sprintf("@视频%d", index)
}
if kind == "audio" {
return fmt.Sprintf("@音频%d", index)
}
return fmt.Sprintf("@图片%d", index)
}
func labelWeight(value string) int {
matches := regexp.MustCompile(`^@(图片|图|视频|音频)([0-9]+)$`).FindStringSubmatch(value)
if len(matches) == 0 {
return 999
}
var index int
_, _ = fmt.Sscanf(matches[2], "%d", &index)
base := 0
if matches[1] == "视频" {
base = 100
} else if matches[1] == "音频" {
base = 200
}
return base + index
}
func fallback(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}