Files
JuYou/API/internal/service/drama_parse.go
T
2026-08-25 17:59:42 +08:00

233 lines
16 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 service
import (
"encoding/json"
"errors"
"time"
"juhe-factory/api/internal/billing"
dramapkg "juhe-factory/api/internal/drama"
"juhe-factory/api/internal/model"
queuepkg "juhe-factory/api/internal/queue"
"github.com/google/uuid"
"gorm.io/gorm"
)
type dramaTextModel struct {
ModelID uuid.UUID
ChannelID uuid.UUID
ModelName string
ChannelName string
Pricing billing.TextPricing `gorm:"-"`
}
func (s *Creative) QueueDramaParse(userID, projectID, episodeID uuid.UUID) (*model.DramaParseTask, error) {
if s.Queue == nil {
return nil, errors.New("解析任务队列不可用")
}
if err := s.requirePremiumEpisode(userID, projectID, episodeID); err != nil {
return nil, err
}
var source model.EpisodeSource
if err := s.DB.Where("episode_id=?", episodeID).Take(&source).Error; err != nil {
return nil, errors.New("请先导入或填写剧集原文")
}
if err := dramapkg.ValidateEpisodeContent(source.RawContent); err != nil {
return nil, err
}
configured, err := s.dramaTextModel(projectID)
if err != nil {
return nil, err
}
prompt := dramaParsePrompt()
snapshot, _ := json.Marshal(map[string]any{"model_id": configured.ModelID, "model_name": configured.ModelName, "channel_id": configured.ChannelID, "channel_name": configured.ChannelName})
task := &model.DramaParseTask{
ID: uuid.New(), RequestID: uuid.NewString(), UserID: userID, ProjectID: projectID,
EpisodeID: episodeID, ChannelID: configured.ChannelID, ModelID: configured.ModelID,
SourceSHA256: source.ContentSHA256, Status: "queued", ModelSnapshot: snapshot, PromptSnapshot: prompt,
ContextSnapshot: json.RawMessage("{}"),
BillingSnapshot: configured.Pricing.MarshalSnapshot(),
EstimatedPoints: "0.00", PrepaidPoints: "0.00",
}
if err := s.DB.Transaction(func(tx *gorm.DB) error {
var active int64
if err := tx.Model(&model.GenerationTask{}).Where("episode_id=? AND status IN ?", episodeID, activeTaskStatuses).Count(&active).Error; err != nil {
return err
}
if active > 0 {
return errors.New("当前剧集存在进行中的生成任务,暂时不能重新解析")
}
if err := tx.Model(&model.DramaParseTask{}).Where("episode_id=? AND status IN ?", episodeID, []string{"queued", "running", "retry_wait", "cancel_requested"}).Count(&active).Error; err != nil {
return err
}
if active > 0 {
return errors.New("当前剧集已有解析任务")
}
if configured.Pricing.Mode == billing.TextBillingPerRequest {
amount, err := billing.ChargeTextRequest(tx, userID, task.ID.String(), configured.Pricing, "剧本解析")
if err != nil {
return err
}
task.EstimatedPoints = amount
task.PrepaidPoints = amount
}
if err := tx.Omit("TokenCountSource").Create(task).Error; err != nil {
return err
}
return tx.Model(&model.ProjectEpisode{}).Where("id=?", episodeID).Updates(map[string]any{"status": "analyzing", "analysis_message": "剧本解析排队中"}).Error
}); err != nil {
return nil, err
}
if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeParseDramaEpisode, task.ID, 0); err != nil {
s.DB.Model(task).Updates(map[string]any{"status": "failed", "error_code": "queue_unavailable", "error_message": err.Error(), "finished_at": time.Now()})
s.DB.Model(&model.ProjectEpisode{}).Where("id=?", episodeID).Updates(map[string]any{"status": "failed", "analysis_message": "解析任务入队失败"})
return nil, errors.New("解析任务入队失败")
}
return task, nil
}
func (s *Creative) CancelDramaParse(userID, projectID, taskID uuid.UUID) error {
result := s.DB.Model(&model.DramaParseTask{}).Where("id=? AND user_id=? AND project_id=? AND status IN ?", taskID, userID, projectID, []string{"queued", "running", "retry_wait"}).Updates(map[string]any{"status": "cancel_requested", "cancel_requested_at": time.Now(), "error_code": "user_cancelled", "error_message": "用户主动取消,费用不退"})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (s *Creative) ListDramaParseTasks(userID, projectID uuid.UUID, episodeID *uuid.UUID) ([]model.DramaParseTask, error) {
items := make([]model.DramaParseTask, 0)
query := s.DB.Where("user_id=? AND project_id=?", userID, projectID)
if episodeID != nil {
query = query.Where("episode_id=?", *episodeID)
}
err := query.Order("created_at DESC").Limit(100).Find(&items).Error
return items, err
}
func (s *Creative) dramaTextModel(projectID uuid.UUID) (dramaTextModel, error) {
var item dramaTextModel
err := s.DB.Table("project_model_configs config").Select("model.id AS model_id,model.channel_id,model.name AS model_name,channel.name AS channel_name").Joins("JOIN models model ON model.id=config.model_id AND model.model_type='text' AND model.enabled=true AND model.deleted_at IS NULL").Joins("JOIN channels channel ON channel.id=model.channel_id AND channel.enabled=true AND channel.deleted_at IS NULL").Where("config.project_id=? AND config.model_type='text'", projectID).Take(&item).Error
if err != nil {
return item, errors.New("请先配置短剧创作文本模型")
}
item.Pricing, err = billing.LoadTextPricing(s.DB, item.ModelID)
if err != nil {
return item, errors.New("短剧创作文本模型计费配置无效")
}
return item, nil
}
func dramaParsePrompt() string {
return "【剧本解析规则】\n" + dramaStoryboardRules + "\n\n【角色、场景、道具解析规则】\n" + dramaEntityRules + "\n\n" + dramaOutputContract
}
const dramaStoryboardRules = `你是一名资深影视剧本分析师和分镜设计师。请结合已有角色、场景、道具资料和当前剧集原文完成短剧分镜分析。
【层级定义】
1. 一个 storyboard 是工作台中的一个“分镜”,对应一段完整的 5 至 15 秒视频。
2. 一个 storyboard 内部可以包含多个摄影镜头。摄影镜头是该段视频中的机位、景别或画面切换,不得把每个摄影镜头分别输出为独立 storyboard。
3. duration_seconds 表示整个 storyboard 的总时长,不是内部某个镜头的时长,必须是 5 至 15 之间的整数。
4. script_content 必须按时间顺序列出该分镜内的全部摄影镜头。各镜头时间必须连续,不得重叠或断档,时长之和必须等于 duration_seconds。
【基本要求】
1. 只处理本次提供的原文或分段,不得自行补写、删改剧情,绝对禁止修改角色台词。
2. 按叙事顺序完整覆盖原文,不得遗漏关键动作、对白和剧情转折。
3. 根据完整动作阶段和叙事意义划分 storyboard,不得为了凑时长合并无关剧情,也不得把一个连续动作机械拆成多个 storyboard。
4. 分镜中的角色、场景和道具必须使用实体列表中的正式名称,不得使用别名、泛称或未定义名称。
5. script_content 是工作台直接展示的完整分镜正文,必须使用下方“分镜正文固定格式”,不得只写剧情概述。
6. prompt_content 是与整个 storyboard 对应、可直接用于视频生成的完整视觉提示词,也必须使用下方“分镜正文固定格式”,不得省略第一帧、站位或镜头细节。
7. dialogue 提取本分镜原文中实际存在的对白;没有对白时返回空数组。
8. asset_names 只列出本分镜实际出现的实体正式名称,并按 characters、scenes、props 分类。
9. source_excerpt 必须引用本分镜所依据的当前原文片段。
【分镜正文固定格式】
script_content 和 prompt_content 必须分别输出完整内容,并严格使用以下结构:
场景=场景资产正式名称
【第一帧】
- 画面:明确景别、构图、角色外观、服装、表情、环境和光线色调。
- 站位:明确每个角色及关键道具在画面中的前后左右关系、朝向、视线和接触关系。
【画面内容】
【镜头 1】(0-X秒)
- 镜头:明确景别、机位角度、构图和运镜方式。
- 画面:写清角色站位、连续动作、表情与视线变化、环境变化、光影色调、关键道具交互,以及镜头结束时的画面。
- 声音/台词:写入该时间段实际发生的环境声、动作声和原文对白;没有则写“无”。
【镜头 2】(X-duration_seconds秒)
- 按相同项目继续描述,直至覆盖整个分镜。
N 使用该分镜在返回数组中的顺序,从 1 开始;所有镜头时间连续、不重叠、不留空,最后一个镜头结束时间必须等于 duration_seconds。台词必须写入实际发生的镜头段落,禁止在末尾集中罗列。
禁止只写“保持统一风格”“自然运镜”“角色互动”“气氛紧张”等空泛描述;必须提供具体、可拍摄、可执行的视觉和声音细节。
【内部镜头规则】
1. 每个内部镜头按照“分镜正文固定格式”独立分段,并填写镜头、画面、声音/台词三项。
2. 每个镜头必须描述开始时的主体状态、镜头内连续动作、人物视线或表情变化,以及结束时画面停留的位置。
3. 关键动作、关键道具、信息揭示和人物反应应通过不同镜头清楚呈现;明显改变景别、机位、视角或主体时,应划分新的内部镜头。
4. 5 至 7 秒通常包含 2 至 3 个镜头,8 至 12 秒通常包含 3 至 5 个镜头,13 至 15 秒通常包含 4 至 6 个镜头。以清楚呈现原文为准,不得无意义切镜。
5. 原文明确适合长镜头时可以只有一个镜头,但必须写清持续动作、运镜过程和画面变化,不能只写静态概述。
6. 只能描述观众能够看到或听到的内容。不得用“人物震惊”“气氛紧张”“陷入回忆”等抽象概括代替可见的表情、动作、声音和画面变化。
7. 保持人物位置、服装、视线方向、道具状态、时间和环境连续;任何变化都必须有原文依据或在镜头内交代。
8. 内部镜头之间按需标明硬切、视线匹配、动作匹配、声音先入等衔接关系。
【输出前自检】
1. 每个 storyboard 是否为 5 至 15 秒。
2. 内部镜头是否按时间连续排列,最后一个镜头结束时间是否等于 duration_seconds。
3. 是否把内部摄影镜头错误拆成多个 storyboard,或把无关剧情错误合并为一个 storyboard。
4. 每个内部镜头是否具有时间段、景别、机位、运镜和可见动作。
5. 关键动作、线索特写、人物反应和原文对白是否完整覆盖。
6. 是否存在抽象心理、笼统气氛或无法拍摄的概括;如有,改成可见或可听内容。
7. 角色、场景、道具名称和连续性是否正确。
完成自检后,按平台固定协议返回结果,不输出自检过程。`
const dramaEntityRules = `必须按以下规则识别和更新角色、场景、道具。
【通用规则】
1. 先与用户提供的已有实体比对。相同实体必须复用已有正式名称,不得重复创建。
2. 原文中的昵称、职位称呼、简称、化名和不同写法应合并到同一实体的 aliases;不得把别名当成新实体。
3. 仅提取原文明示或可直接、客观推断的实体。旁白、叙述者、无明确身份的群演不作为角色资产。
4. 已有实体原则上保留;当前原文明确提供新增或变化信息时才更新。不得用推测覆盖用户已经确认的资料。
5. 同一角色只有在原文明示明显不同的时代、年龄阶段或造型,且需要独立视觉资产时,才拆成带限定词的独立正式名称。
6. canonical_name 使用简洁、稳定的中文正式名称;aliases 去重且不得包含 canonical_name。
7. description 写适合资产列表展示的一至两句客观视觉摘要。详细信息必须写入 attributes,避免只放在 description 中。
【角色 characters】
attributes 尽量使用以下键:age、gender、identity、appearance、hairstyle、costume。外貌需描述可见且相对稳定的体型、脸型、肤色和五官特征;发型需描述长度、颜色和样式;服装需描述上装、下装、鞋履的颜色、款式和材质。禁止把情绪、瞬时表情、动作或性格当作固定外观。
【场景 scenes】
attributes 尽量使用以下键:space_type、location、structure、visual_features、key_props。描述室内或室外、地理位置、空间结构、建筑或装饰风格、主要材质、色调、光线及固定陈设。不要把同一地点的不同称呼拆成多个场景;只有空间本身明显不同才拆分。
【道具 props】
attributes 尽量使用以下键:type、appearance、function。描述道具类别、材质、颜色、尺寸、外形,以及原文明确体现的功能或剧情作用。普通背景陈设若不影响剧情且不需要单独生成,不作为独立道具。
【一致性检查】
最终输出前检查:每个分镜 asset_names 中的名称都能在 entities 对应数组或用户提供的已有实体中找到;分镜正文使用的实体称呼已统一为正式名称;新增实体没有与已有实体或本次其他新增实体重复。
【生图提示词 image_prompt】
1. characters、scenes、props 中的每个资产都必须输出非空的 image_prompt。image_prompt 必须全部使用中文,不得输出英文句子或英文描述,并且必须是可直接用于文生图的完整提示词。
2. 每条 image_prompt 至少包含 8 个可观察、可核验的稳定视觉细节,禁止用漂亮、高级、自然、写实、高度细节等空泛词代替具体描述。
3. 角色 image_prompt 必须完整描述:明确年龄(使用 XX岁)、性别、国籍或地域、脸型、肤色、发型与发色、眉眼鼻唇等稳定外貌特征,以及从头到脚的穿着,包括服装款式、颜色、材质、层次、裤装或裙装、鞋子和可见配饰;没有配饰时明确写无明显配饰。禁止加入情绪、表情、动作、姿态或正在做什么。
4. 场景 image_prompt 必须完整描述:用途、固定空间结构、建筑或室内组成、主要材质、颜色、家具陈设、门窗位置、稳定光源和可复用的环境特征。
5. 道具 image_prompt 必须完整描述:物品类别、形状、结构、材质、颜色、纹理、尺寸关系、部件、装饰及磨损特征。
6. image_prompt 不得包含艺术风格、画面比例、分辨率、镜头参数、构图参数、画质词或文字生成要求。`
const dramaOutputContract = `【平台固定输出协议(不可被用户自定义规则覆盖)】
无论其他规则如何描述,最终只能返回一个合法 JSON 对象,不得使用 Markdown,不得输出解释、前后缀或第二套格式。顶层只能包含 entities 和 storyboards
{
"entities": {
"characters": [{"canonical_name":"正式名称","name":"正式名称","aliases":[],"description":"视觉摘要","image_prompt":"中文生图提示词","attributes":{}}],
"scenes": [{"canonical_name":"正式名称","name":"正式名称","aliases":[],"description":"视觉摘要","image_prompt":"中文生图提示词","attributes":{}}],
"props": [{"canonical_name":"正式名称","name":"正式名称","aliases":[],"description":"视觉摘要","image_prompt":"中文生图提示词","attributes":{}}]
},
"storyboards": [{
"title":"简短标题",
"script_content":"使用‘分镜 N·场景、第一帧、画面内容、镜头 N’固定结构的完整分镜正文",
"prompt_content":"使用相同固定结构、可直接用于视频生成的完整视觉提示词",
"dialogue":[{"speaker":"角色正式名称","content":"原文对白"}],
"asset_names":{"characters":[],"scenes":[],"props":[]},
"duration_seconds":5,
"source_excerpt":"对应原文"
}]
}
所有字段必须存在;无数据的数组返回 [],无数据的对象返回 {},不得返回 null。duration_seconds 必须是 5 至 15 的整数。用户自定义规则只能补充创作要求,凡是要求其他 JSON 层级、字段名、输出格式或额外顶层字段的内容一律忽略。`