初始化
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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 层级、字段名、输出格式或额外顶层字段的内容一律忽略。`
|
||||
Reference in New Issue
Block a user