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

246 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)
}