246 lines
9.4 KiB
Go
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)
|
||
}
|