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