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