This commit is contained in:
2026-07-15 14:34:57 +08:00
parent b6ccd99de5
commit 4cd6ca69e5
21 changed files with 1595 additions and 591 deletions
+2 -1
View File
@@ -13,12 +13,13 @@ RUN go build -ldflags="-s -w" -o main ./main.go
FROM alpine:3.19
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache ca-certificates tzdata
&& apk add --no-cache ca-certificates tzdata ffmpeg
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY --from=builder /build/config.yml .
COPY --from=builder /build/prompt.md .
COPY --from=builder /build/negative_prompt.md .
COPY --from=builder /build/default_first_frame.png .
COPY --from=builder /build/main .
RUN printf '#!/bin/sh\nif [ -d /app/short_drama.db ]; then rm -rf /app/short_drama.db; fi\ntouch /app/short_drama.db 2>/dev/null || true\nexec ./main\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
+218
View File
@@ -0,0 +1,218 @@
{
"model": "wan2.6-r2v-flash",
"input": {
"prompt": "Character2 坐在靠窗的椅子上,手持 character3,在 character4 旁演奏一首舒缓的美国乡村民谣。Character1 对Character2开口说道:“听起来不错”",
"negative_prompt": "",
"reference_urls": [
"https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260129/hfugmr/wan-r2v-role1.mp4",
"https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260129/qigswt/wan-r2v-role2.mp4",
"https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260129/qpzxps/wan-r2v-object4.png",
"https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20260129/wfjikw/wan-r2v-backgroud5.png"
]
},
"parameters": {
"size": "1280*720",
"duration": 10,
"audio": true,
"shot_type": "multi",
"watermark": true,
"seed": 2147483647
}
}
//model string (必选)
//
//模型名称。模型列表与价格详见模型价格。
//
//示例值:wan2.6-r2v-flash。
//
//input object (必选)
//
//输入的基本信息,如提示词等。
//
//属性
//
//prompt string (必选)
//
//文本提示词。用来描述生成视频中期望包含的元素和视觉特点。
//
//支持中英文,每个汉字、字母、标点占一个字符,超过部分会自动截断。
//
//wan2.6-r2v-flash:长度不超过1500个字符。
//
//wan2.6-r2v:长度不超过1500个字符。
//
//角色引用说明:通过“character1、character2”这类标识引用参考角色,每个参考(视频或图像)仅包含单一角色。模型仅通过此方式识别参考中的角色。
//
//示例值:character1在沙发上开心地看电影。
//
//提示词的使用技巧请参见文生视频/图生视频Prompt指南。
//
//negative_prompt string (可选)
//
//反向提示词,用来描述不希望在视频画面中出现的内容,可以对视频画面进行限制。
//
//支持中英文,长度不超过500个字符,超过部分会自动截断。
//
//示例值:低分辨率、错误、最差质量、低质量、残缺、多余的手指、比例不良等。
//
//reference_urls array[string] (必选)
//
//重要
//reference_urls直接影响费用,计费规则请参见计费与限流。
//
//上传的参考文件 URL 数组,支持传入视频和图像。用于提取角色形象与音色(如有),以生成符合参考特征的视频。
//
//每个 URL 可指向 一张图像 或 一段视频:
//
//图像数量:05。
//
//视频数量:03。
//
//总数限制:图像 + 视频 ≤ 5。
//
//传入多个参考文件时,按照数组顺序定义角色的顺序。即第 1 个 URL 对应 character1,第 2 个对应 character2,以此类推。
//
//每个参考文件仅包含一个主体角色。例如 character1 为小女孩,character2 为闹钟。
//
//支持输入的格式:
//
//公网URL:
//
//支持 HTTP 或 HTTPS 协议。
//
//示例值:https://cdn.translate.alibaba.com/xxx.png。
//
//临时URL
//
//支持OSS协议,必须通过上传文件获取临时 URL。
//
//示例值:oss://dashscope-instant/xxx/xxx.png。
//
//参考视频要求:
//
//格式:MP4、MOV。
//
//时长:1s30s。
//
//视频大小:不超过100MB。
//
//参考图像要求:
//
//格式:JPEG、JPG、PNG(不支持透明通道)、BMP、WEBP。
//
//分辨率:宽高均需在[240,8000]像素之间。
//
//图像大小:不超过20MB。
//
//示例值:["https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/xxx.mp4", "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/xxx.jpg"]。
//
//已废弃字段
//
//parameters object (可选)
//
//图像处理参数。如设置视频分辨率、开启prompt智能改写、添加水印等。
//
//属性
//
//size string (可选)
//
//重要
//size直接影响费用,费用 = 单价(基于分辨率)× 时长(秒)。同一模型:1080P > 720P ,请在调用前确认模型价格。
//
//size必须设置为具体数值(如 1280*720),而不是 1:1或720P。
//
//指定生成的视频分辨率,格式为宽*高。该参数的默认值和可用枚举值依赖于 model 参数,规则如下:
//
//wan2.6-r2v-flash:默认值为 1920*10801080P)。可选分辨率:720P、1080P对应的所有分辨率。
//
//wan2.6-r2v:默认值为 1920*10801080P)。可选分辨率:720P、1080P对应的所有分辨率。
//
//720P档位:可选的视频分辨率及其对应的视频宽高比为:
//
//1280*72016:9。
//
//720*12809:16。
//
//960*9601:1。
//
//1088*8324:3。
//
//832*10883:4。
//
//1080P档位:可选的视频分辨率及其对应的视频宽高比为:
//
//1920*1080 16:9。
//
//1080*1920 9:16。
//
//1440*1440 1:1。
//
//1632*1248 4:3。
//
//1248*1632 3:4。
//
//duration integer (可选)
//
//重要
//duration直接影响费用。费用 = 单价(基于分辨率)× 时长(秒),请在调用前确认模型价格。
//
//生成视频的时长,单位为秒。
//
//wan2.6-r2v-flash:取值为[2, 10]之间的整数。默认值为5。
//
//wan2.6-r2v:取值为[2, 10]之间的整数。默认值为5。
//
//示例值:5。
//
//shot_type string (可选)
//
//指定生成视频的镜头类型,即视频是由一个连续镜头还是多个切换镜头组成。
//
//参数优先级:shot_type > prompt。例如,若 shot_type设置为"single",即使 prompt 中包含“生成多镜头视频”,模型仍会输出单镜头视频。
//
//可选值:
//
//single:默认值,输出单镜头视频
//
//multi:输出多镜头视频。
//
//示例值:single。
//
//说明
//当希望严格控制视频的叙事结构(如产品展示用单镜头、故事短片用多镜头),可通过此参数指定。
//
//audio boolean (可选)
//
//重要
//audio直接影响费用,有声视频与无声视频价格不同,请在调用前确认模型价格。
//
//支持模型:wan2.6-r2v-flash。
//
//是否生成有声视频。
//
//可选值:
//
//true:默认值,输出有声视频。
//
//false:输出无声视频。
//
//示例值:true。
//
//watermark boolean (可选)
//
//是否添加水印标识,水印位于视频右下角,文案固定为“AI生成”。
//
//false:默认值,不添加水印。
//
//true:添加水印。
//
//示例值:false。
//
//seed integer (可选)
//
//随机数种子,取值范围为[0, 2147483647]。
//
//未指定时,系统自动生成随机种子。若需提升生成结果的可复现性,建议固定seed值。
//
//使使 seed
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 5.4 KiB

+1
View File
@@ -0,0 +1 @@
低分辨率、模糊、失真、扭曲、变形、闪烁、抖动、过度曝光、色彩失真、画面杂乱、构图不当、主体不完整、多余物体、残缺、面部扭曲、肢体不自然、比例失调、低质量、最差质量、不良渲染
BIN
View File
Binary file not shown.
+69 -19
View File
@@ -6,10 +6,11 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
)
// ModelConfig 模型配置
@@ -39,7 +40,7 @@ func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*Ch
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 60 * time.Second
timeout = 180 * time.Second
}
body, err := buildReqBody(cfg.ModelName, req)
@@ -48,13 +49,14 @@ func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*Ch
}
url := trimSlashes(cfg.BaseURL) + "/v1/chat/completions"
g.Log().Infof(ctx, "ChatAPI 开始调用 model=%s timeout=%v max_retries=3 body_size=%d", cfg.ModelName, timeout, len(body))
var lastErr error
maxRetries := 3
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
wait := time.Duration(1<<(attempt-1)) * time.Second
log.Printf("API限流重试第%d次(等待%v)...", attempt, wait)
g.Log().Infof(ctx, "ChatAPI 限流重试第%d次(等待%v)", attempt, wait)
select {
case <-ctx.Done():
return nil, ctx.Err()
@@ -64,10 +66,13 @@ func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*Ch
result, doErr := doChatRequest(ctx, url, cfg.APIKey, body, timeout)
if doErr == nil {
g.Log().Infof(ctx, "ChatAPI 调用成功 url=%s tool_calls=%d content_len=%d",
url, len(result.ToolCalls), len(result.Content))
return result, nil
}
lastErr = doErr
g.Log().Warningf(ctx, "ChatAPI 请求失败(attempt=%d/%d): %v", attempt+1, maxRetries+1, doErr)
// 只有限流或服务端错误才重试
errStr := lastErr.Error()
if !strings.Contains(errStr, "limit_requests") &&
@@ -79,6 +84,7 @@ func CallChatModel(ctx context.Context, cfg *ModelConfig, req *ChatRequest) (*Ch
}
}
g.Log().Errorf(ctx, "ChatAPI %d次重试后最终失败: %v", maxRetries+1, lastErr)
return nil, lastErr
}
@@ -90,18 +96,27 @@ func doChatRequest(ctx context.Context, url, apiKey string, body []byte, timeout
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
httpReq.Header.Set("Content-Type", "application/json")
start := time.Now()
client := &http.Client{Timeout: timeout}
resp, err := client.Do(httpReq)
elapsed := time.Since(start)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
return nil, fmt.Errorf("请求失败(耗时%v): %w", elapsed, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
return nil, fmt.Errorf("读取响应失败(状态码=%d): %w", resp.StatusCode, err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("API返回错误状态码=%d body=%s", resp.StatusCode, string(respBody))
}
g.Log().Infof(ctx, "ChatAPI 响应完成 status=%d body_len=%d elapsed=%v",
resp.StatusCode, len(respBody), elapsed)
return parseRespBody(respBody)
}
@@ -145,24 +160,37 @@ type apiRespBody struct {
}
type apiChoice struct {
Index int `json:"index"`
Message apiMsg `json:"message"`
Index int `json:"index"`
Message apiRespMsg `json:"message"`
}
type apiMsg struct {
Content string `json:"content"`
ToolCalls []apiToolCall `json:"tool_calls,omitempty"`
// apiRespMsg 响应消息体(arguments 使用 json.RawMessage 兼容对象和字符串)
type apiRespMsg struct {
Content string `json:"content"`
ToolCalls []apiRespToolCall `json:"tool_calls,omitempty"`
}
type apiRespToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function apiRespFuncCall `json:"function"`
}
type apiRespFuncCall struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
type apiToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function apiFuncCall `json:"function"`
ID string `json:"id"`
Type string `json:"type"`
Function apiReqFuncCall `json:"function"`
}
type apiFuncCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
// apiReqFuncCall 请求中的 function callarguments 为 json.RawMessage 避免二次编码)
type apiReqFuncCall struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
func buildReqBody(model string, req *ChatRequest) ([]byte, error) {
@@ -201,12 +229,16 @@ func toAPIMessages(msgs []*ChatMessage) []apiMessage {
if len(m.ToolCalls) > 0 {
om.ToolCalls = make([]apiToolCall, 0, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
args := tc.Arguments
if args == "" {
args = "{}"
}
om.ToolCalls = append(om.ToolCalls, apiToolCall{
ID: tc.ID,
Type: "function",
Function: apiFuncCall{
Function: apiReqFuncCall{
Name: tc.Name,
Arguments: tc.Arguments,
Arguments: json.RawMessage(args),
},
})
}
@@ -233,16 +265,34 @@ func parseRespBody(data []byte) (*ChatResponse, error) {
if len(msg.ToolCalls) > 0 {
cr.ToolCalls = make([]*ToolCall, 0, len(msg.ToolCalls))
for _, tc := range msg.ToolCalls {
args := resolveArguments(tc.Function.Arguments)
cr.ToolCalls = append(cr.ToolCalls, &ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
Arguments: args,
})
}
}
return cr, nil
}
// resolveArguments 将 json.RawMessage 的参数转为字符串
// API 可能返回 "arguments": "{\"key\":\"val\"}"(字符串)或 "arguments": {"key":"val"}(对象)
func resolveArguments(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
// 如果是 JSON 字符串(以 " 开头),直接提取字符串值
if raw[0] == '"' {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
}
// 否则是 JSON 对象,重新序列化回字符串
return string(raw)
}
func trimSlashes(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
+11
View File
@@ -81,6 +81,17 @@ func (a *ReActAgent) Run(ctx context.Context, userInput string) (string, error)
continue
}
if tc.Arguments == "" {
g.Log().Warningf(ctx, "ReAct step %d: 工具 %s 参数为空,跳过", step+1, tc.Name)
messages = append(messages, &ChatMessage{
Role: RoleTool,
Content: "工具参数为空",
Name: tc.Name,
ToolCallID: tc.ID,
})
continue
}
var args map[string]any
if err := json.Unmarshal([]byte(tc.Arguments), &args); err != nil {
g.Log().Warningf(ctx, "ReAct step %d: 参数解析失败: %v", step+1, err)
+1
View File
@@ -7,6 +7,7 @@ const (
EpisodeStatusCompleted = "completed"
EpisodeStatusFailed = "failed"
TaskStatusPending = "pending"
TaskStatusGenerating = "generating"
TaskStatusReview = "review"
TaskStatusCompleted = "completed"
@@ -2,7 +2,6 @@ package controller
import (
"context"
"fmt"
"video-factory/shortdrama/model/dto"
"video-factory/shortdrama/model/entity"
@@ -18,15 +17,6 @@ func (c *config) Get(ctx context.Context, req *dto.GetModelConfigReq) (res *dto.
}
func (c *config) Save(ctx context.Context, req *dto.SaveModelConfigReq) (res *struct{}, err error) {
if req.MinSingleDuration < 1 {
return nil, fmt.Errorf("单次生成最小时长必须大于等于1")
}
if req.MaxSingleDuration < 1 {
return nil, fmt.Errorf("单次生成最大时长必须大于等于1")
}
if req.MinSingleDuration > req.MaxSingleDuration {
return nil, fmt.Errorf("单次生成最小时长不能大于最大时长")
}
return nil, service.ConfigService.Save(ctx, req)
}
+17 -5
View File
@@ -23,6 +23,8 @@ func init() {
episode_id INTEGER NOT NULL DEFAULT 0, -- 剧集ID
segment_idx INTEGER NOT NULL DEFAULT 0, -- 段索引
status TEXT NOT NULL DEFAULT 'pending', -- 任务状态
script TEXT NOT NULL DEFAULT '', -- 段脚本内容
duration INTEGER NOT NULL DEFAULT 0, -- 段时长(秒)
error_message TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT (datetime('now','localtime')),
updated_at DATETIME DEFAULT (datetime('now','localtime'))
@@ -34,11 +36,6 @@ func init() {
`ALTER TABLE `+public.TableNameGenerationTask+` ADD COLUMN segment_idx INTEGER NOT NULL DEFAULT 0`); err != nil {
g.Log().Debugf(ctx, "添加 segment_idx 列失败(可能已存在): %v", err)
}
// 迁移:添加 script_path 列
if _, err := g.DB().Exec(ctx,
`ALTER TABLE `+public.TableNameGenerationTask+` ADD COLUMN script_path TEXT NOT NULL DEFAULT ''`); err != nil {
g.Log().Debugf(ctx, "添加 script_path 列失败(可能已存在): %v", err)
}
// 迁移:添加 video_task_id 列
if _, err := g.DB().Exec(ctx,
`ALTER TABLE `+public.TableNameGenerationTask+` ADD COLUMN video_task_id TEXT NOT NULL DEFAULT ''`); err != nil {
@@ -69,6 +66,21 @@ func init() {
`ALTER TABLE `+public.TableNameGenerationTask+` DROP COLUMN steps_data`); err != nil {
g.Log().Debugf(ctx, "删除 steps_data 列失败(可能已不存在): %v", err)
}
// 迁移:删除废弃的 script_path 列
if _, err := g.DB().Exec(ctx,
`ALTER TABLE `+public.TableNameGenerationTask+` DROP COLUMN script_path`); err != nil {
g.Log().Debugf(ctx, "删除 script_path 列失败(可能已不存在): %v", err)
}
// 迁移:添加 script 列
if _, err := g.DB().Exec(ctx,
`ALTER TABLE `+public.TableNameGenerationTask+` ADD COLUMN script TEXT NOT NULL DEFAULT ''`); err != nil {
g.Log().Debugf(ctx, "添加 script 列失败(可能已存在): %v", err)
}
// 迁移:添加 duration 列
if _, err := g.DB().Exec(ctx,
`ALTER TABLE `+public.TableNameGenerationTask+` ADD COLUMN duration INTEGER NOT NULL DEFAULT 0`); err != nil {
g.Log().Debugf(ctx, "添加 duration 列失败(可能已存在): %v", err)
}
}
func (d *generationTaskDao) Insert(ctx context.Context, data *entity.GenerationTask) (id int64, err error) {
+10 -13
View File
@@ -27,8 +27,6 @@ func init() {
video_api_key TEXT NOT NULL DEFAULT '',
video_base_url TEXT NOT NULL DEFAULT '',
video_model_name TEXT NOT NULL DEFAULT '',
max_single_duration INTEGER NOT NULL DEFAULT 15,
min_single_duration INTEGER NOT NULL DEFAULT 5,
video_schema TEXT NOT NULL DEFAULT '',
video_task_callback_url TEXT NOT NULL DEFAULT '',
price_per_second INTEGER NOT NULL DEFAULT 0,
@@ -40,6 +38,7 @@ func init() {
// 清理旧字段
for _, col := range []string{
"max_single_duration", "min_single_duration",
"chat_provider", "video_provider",
"video_no_duration_support",
"video_model_category", "image_model_name",
@@ -75,17 +74,15 @@ func init() {
count, _ := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Count()
if count == 0 {
_, err := g.DB().Model(public.TableNameModelConfig).Ctx(ctx).Data(g.Map{
"chat_api_key": "sk-ws-H.RXDDPMR.E8Rn.MEUCIQD00xjpXeNJlnXQejmbFTyC5ILHF8hLB2at4QXU6nf6GwIgae5TA8siaylUxMBDQcOWFfNx6hxB4rFsdCxoV6md810",
"chat_base_url": "https://dashscope.aliyuncs.com/compatible-mode",
"chat_model_name": "qwen-math-turbo",
"max_tokens": 8192,
"temperature": 0.7,
"video_api_key": "sk-ws-H.RXDDPMR.E8Rn.MEUCIQD00xjpXeNJlnXQejmbFTyC5ILHF8hLB2at4QXU6nf6GwIgae5TA8siaylUxMBDQcOWFfNx6hxB4rFsdCxoV6md810",
"video_base_url": "https://ws-0mn9581u5vt4lcx9.cn-beijing.maas.aliyuncs.com/api/v1/tasks",
"video_task_callback_url": "https://ws-0mn9581u5vt4lcx9.cn-beijing.maas.aliyuncs.com/api/v1/tasks/{task_id}",
"video_model_name": "wan2.7-r2v",
"max_single_duration": 5,
"min_single_duration": 2,
"chat_api_key": "",
"chat_base_url": "",
"chat_model_name": "",
"max_tokens": 4096,
"temperature": 0.85,
"video_api_key": "",
"video_base_url": "",
"video_task_callback_url": "",
"video_model_name": "",
"price_per_second": 10,
"created_at": gtime.Now().Format("Y-m-d H:i:s"),
"updated_at": gtime.Now().Format("Y-m-d H:i:s"),
+1 -3
View File
@@ -31,9 +31,7 @@ type SaveModelConfigReq struct {
VideoApiKey string `v:"required" json:"videoApiKey" dc:"视频模型API密钥"`
VideoBaseUrl string `v:"required|url" json:"videoBaseUrl" dc:"视频模型API接口地址"`
VideoModelName string `v:"required" json:"videoModelName" dc:"视频模型名称"`
MaxSingleDuration int `v:"required" json:"maxSingleDuration" dc:"单段最大时长"`
MinSingleDuration int `v:"required" json:"minSingleDuration" dc:"单段最小时长"`
VideoSchema *gjson.Json `json:"videoSchema" dc:"视频生成模型schema"`
VideoSchema *gjson.Json `json:"videoSchema" dc:"视频生成模型schema(JSON, 含duration/ref/params/sizes)"`
PricePerSecond int `v:"required|min:1" json:"pricePerSecond" dc:"每秒价格(分)"`
VideoTaskCallbackUrl string `json:"videoTaskCallbackUrl" dc:"视频生成任务回调地址"`
}
+13 -12
View File
@@ -3,16 +3,17 @@ package entity
import "github.com/gogf/gf/v2/os/gtime"
type GenerationTask struct {
Id int64 `orm:"id" json:"id" dc:"任务ID" json:"id" json:"id" dc:"任务ID"`
DramaId int64 `orm:"drama_id" json:"dramaId" dc:"短剧ID" json:"drama_id" json:"dramaId" dc:"短剧ID"`
EpisodeId int64 `orm:"episode_id" json:"episodeId" dc:"剧集ID" json:"episode_id" json:"episodeId" dc:"剧集ID"`
SegmentIdx int `orm:"segment_idx" json:"segmentIdx" dc:"段索引" json:"segment_idx" json:"segmentIdx" dc:"段索引"`
Status string `orm:"status" json:"status" dc:"任务状态(pending/generating/review/completed/failed)" json:"status" json:"status" dc:"任务状态(pending/generating/review/completed/failed)"`
ScriptPath string `orm:"script_path" json:"scriptPath" dc:"脚本文件路径" json:"script_path" json:"scriptPath" dc:"脚本文件路径"`
VideoTaskId string `orm:"video_task_id" json:"videoTaskId" dc:"视频合成任务ID" json:"video_task_id" json:"videoTaskId" dc:"视频合成任务ID"`
VideoUrl string `orm:"video_url" json:"videoUrl" dc:"视频文件路径" json:"video_url" json:"videoUrl" dc:"视频文件路径"`
NumSegments int `orm:"num_segments" json:"numSegments" dc:"本集总段数" json:"num_segments" json:"numSegments" dc:"本集总段数"`
ErrorMessage string `orm:"error_message" json:"errorMessage" dc:"错误信息" json:"error_message" json:"errorMessage" dc:"错误信息"`
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间" json:"created_at" json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间" json:"updated_at" json:"updatedAt" dc:"更新时间"`
Id int64 `orm:"id" json:"id" dc:"任务ID"`
DramaId int64 `orm:"drama_id" json:"dramaId" dc:"短剧ID"`
EpisodeId int64 `orm:"episode_id" json:"episodeId" dc:"剧集ID"`
SegmentIdx int `orm:"segment_idx" json:"segmentIdx" dc:"段索引"`
Status string `orm:"status" json:"status" dc:"任务状态(pending/generating/review/completed/failed)"`
Script string `orm:"script" json:"script" dc:"脚本内容"`
Duration int `orm:"duration" json:"duration" dc:"段时长(秒)"`
VideoTaskId string `orm:"video_task_id" json:"videoTaskId" dc:"视频合成任务ID"`
VideoUrl string `orm:"video_url" json:"videoUrl" dc:"视频文件路径"`
NumSegments int `orm:"num_segments" json:"numSegments" dc:"本集总段数"`
ErrorMessage string `orm:"error_message" json:"errorMessage" dc:"错误信息"`
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间"`
UpdatedAt *gtime.Time `orm:"updated_at" json:"updatedAt" dc:"更新时间"`
}
+1 -3
View File
@@ -15,9 +15,7 @@ type ModelConfig struct {
VideoApiKey string `orm:"video_api_key" json:"videoApiKey" dc:"视频模型API密钥"`
VideoBaseUrl string `orm:"video_base_url" json:"videoBaseUrl" dc:"视频模型接口地址"`
VideoModelName string `orm:"video_model_name" json:"videoModelName" dc:"视频模型名称"`
MaxSingleDuration int `orm:"max_single_duration" json:"maxSingleDuration" dc:"单段最大时长"`
MinSingleDuration int `orm:"min_single_duration" json:"minSingleDuration" dc:"单段最小时长"`
VideoSchema string `orm:"video_schema" json:"videoSchema" dc:"视频生成模型schema"`
VideoSchema string `orm:"video_schema" json:"videoSchema" dc:"视频生成模型schema(JSON, 含duration/ref/prams/sizes)"`
PricePerSecond int `orm:"price_per_second" json:"pricePerSecond" dc:"每秒价格(分)"`
VideoTaskCallbackUrl string `orm:"video_task_callback_url" json:"videoTaskCallbackUrl" dc:"视频生成任务回调地址"`
CreatedAt *gtime.Time `orm:"created_at" json:"createdAt" dc:"创建时间"`
+1 -1
View File
@@ -30,5 +30,5 @@ type SegmentCharacter struct {
type VideoRef struct {
Type string `json:"type"` // "character" | "scene" | "prop"
Name string `json:"name"` // 实体名称
MediaURL string `json:"mediaUrl"` // 参考素材 URLbase64 data URL 或 HTTP URL
MediaURL string `json:"mediaUrl"` // 参考素材 URLAPI 要求 HTTPS 公网 URL 或 OSS URL
}
+3 -126
View File
@@ -2,10 +2,6 @@ package service
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"video-factory/shortdrama/dao"
"video-factory/shortdrama/model/dto"
@@ -88,133 +84,14 @@ func (s *configService) SavePaymentConfig(ctx context.Context, cfg *entity.Payme
return dao.PaymentConfigDao.Save(ctx, cfg)
}
// syncModelDurationFromAPI 查询模型API,获取模型支持的参数和时长范围
// syncModelDurationFromAPI 保存前校验配置完整性,设置默认值
func syncModelDurationFromAPI(ctx context.Context, cfg *entity.ModelConfig) {
if cfg.VideoApiKey == "" || cfg.VideoBaseUrl == "" || cfg.VideoModelName == "" {
return
}
if cfg.MinSingleDuration <= 0 {
cfg.MinSingleDuration = 2
g.Log().Infof(ctx, "使用默认单段最小时长: 2 秒")
}
if cfg.MaxSingleDuration <= 0 {
cfg.MaxSingleDuration = 15
g.Log().Infof(ctx, "使用默认单段最大时长: 15 秒")
}
if cfg.Temperature <= 0 {
cfg.Temperature = 0.85
g.Log().Infof(ctx, "使用默认 Temperature: 0.85")
}
if cfg.ChatApiKey == "" || cfg.ChatBaseUrl == "" || cfg.ChatModelName == "" {
return
}
modelsURL := strings.TrimRight(cfg.ChatBaseUrl, "/") + "/models"
chatReq, err := http.NewRequestWithContext(ctx, "GET", modelsURL, nil)
if err != nil {
g.Log().Warningf(ctx, "创建对话模型查询请求失败: %v", err)
return
}
chatReq.Header.Set("Authorization", "Bearer "+cfg.ChatApiKey)
chatReq.Header.Set("Content-Type", "application/json")
chatResp, err := http.DefaultClient.Do(chatReq)
if err != nil {
g.Log().Warningf(ctx, "查询对话模型列表API失败: %v(不影响配置保存,max_tokens将使用默认值)", err)
return
}
defer chatResp.Body.Close()
chatBody, _ := io.ReadAll(chatResp.Body)
var chatModelList struct {
Object string `json:"object"`
Data []struct {
Id string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
} `json:"data"`
FirstId string `json:"first_id"`
LastId string `json:"last_id"`
HasMore bool `json:"has_more"`
}
if err := json.Unmarshal(chatBody, &chatModelList); err != nil || chatModelList.Object != "list" {
g.Log().Debugf(ctx, "解析对话模型列表响应失败(可能非OpenAI兼容API: %v", err)
return
}
chatModelName := strings.ToLower(cfg.ChatModelName)
if cfg.MaxTokens <= 0 {
cfg.MaxTokens = inferChatMaxTokens(chatModelName)
g.Log().Infof(ctx, "根据模型名称推断 max_tokens: %d(用户在表单中可手动修改)", cfg.MaxTokens)
}
if cfg.Temperature <= 0 {
cfg.Temperature = inferChatTemperature(chatModelName)
g.Log().Infof(ctx, "根据模型名称推断 Temperature: %.2f(用户在表单中可手动修改)", cfg.Temperature)
}
}
func inferChatMaxTokens(modelName string) int {
switch {
case strings.Contains(modelName, "qwen-math"):
return 32768
case strings.Contains(modelName, "qwen3"):
return 65536
case strings.Contains(modelName, "qwen2.5"):
return 32768
case strings.Contains(modelName, "qwen-max"):
return 8192
case strings.Contains(modelName, "qwen-plus"):
return 16384
case strings.Contains(modelName, "gpt-4o"):
return 16384
case strings.Contains(modelName, "gpt-4"):
return 8192
case strings.Contains(modelName, "gpt-3.5"):
return 4096
case strings.Contains(modelName, "deepseek"):
return 8192
case strings.Contains(modelName, "doubao") || strings.Contains(modelName, "豆包"):
return 65536
case strings.Contains(modelName, "glm"):
return 8192
case strings.Contains(modelName, "ernie"):
return 8192
default:
return 4096
}
}
func inferChatTemperature(modelName string) float64 {
switch {
case strings.Contains(modelName, "qwen-math"):
return 0.5
case strings.Contains(modelName, "qwen3"):
return 0.7
case strings.Contains(modelName, "qwen2.5"):
return 0.7
case strings.Contains(modelName, "qwen-max"):
return 0.8
case strings.Contains(modelName, "qwen-plus"):
return 0.8
case strings.Contains(modelName, "gpt-4o"):
return 0.8
case strings.Contains(modelName, "gpt-4"):
return 0.7
case strings.Contains(modelName, "gpt-3.5"):
return 0.7
case strings.Contains(modelName, "deepseek"):
return 0.7
case strings.Contains(modelName, "doubao") || strings.Contains(modelName, "豆包"):
return 0.95
case strings.Contains(modelName, "glm"):
return 0.8
case strings.Contains(modelName, "ernie"):
return 0.8
default:
return 0.7
cfg.MaxTokens = 4096
g.Log().Infof(ctx, "使用默认 MaxTokens: 4096")
}
}
File diff suppressed because it is too large Load Diff
+364 -19
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -50,18 +51,35 @@ func (s *dramaService) AddEpisode(ctx context.Context, dramaId int64, title, des
}
index = maxIdx + 1
}
id, err := dao.Episode.Insert(ctx, &entity.Episode{
DramaId: dramaId,
Index: index,
Title: title,
Description: description,
Script: script,
Status: consts.EpisodeStatusPending,
})
if err != nil {
return 0, err
// 无脚本时直接插入剧集(无需创建任务)
if script == "" {
return dao.Episode.Insert(ctx, &entity.Episode{
DramaId: dramaId, Index: index, Title: title,
Description: description, Script: script,
Status: consts.EpisodeStatusPending,
})
}
return id, nil
// 有脚本时用事务同时写入剧集和任务
var epId int64
now := time.Now().Format("2006-01-02 15:04:05")
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
r, e := tx.Model(public.TableNameEpisode).Ctx(ctx).Data(g.Map{
"drama_id": dramaId, "idx": index, "title": title,
"description": description, "script": script,
"status": consts.EpisodeStatusPending,
"created_at": now, "updated_at": now,
}).Insert()
if e != nil {
return e
}
id, e := r.LastInsertId()
if e != nil {
return e
}
epId = id
return createPendingTasks(ctx, dramaId, epId, script, tx)
})
return epId, err
}
func (s *dramaService) UpdateEpisode(ctx context.Context, dramaId, epId int64, title, description, script string, index int) error {
@@ -80,17 +98,29 @@ func (s *dramaService) UpdateEpisode(ctx context.Context, dramaId, epId int64, t
}
if script != "" {
e.Script = script
if e.Status == "" {
e.Status = consts.EpisodeStatusPending
}
e.Status = consts.EpisodeStatusPending
}
if index > 0 {
e.Index = index
}
if err := dao.Episode.Update(ctx, epId, e); err != nil {
return err
}
return nil
needsTaskUpdate := script != ""
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 更新剧集
if _, e := tx.Model(public.TableNameEpisode).Ctx(ctx).Data(e).Where("id", epId).Update(); e != nil {
return e
}
// 脚本变动时重创建任务
if needsTaskUpdate {
if _, e := tx.Model(public.TableNameGenerationTask).Ctx(ctx).Where("episode_id", epId).Delete(); e != nil {
return e
}
if e := createPendingTasks(ctx, dramaId, epId, script, tx); e != nil {
return fmt.Errorf("重新创建待生成任务失败: %w", e)
}
}
return nil
})
}
func (s *dramaService) DeleteEpisode(ctx context.Context, dramaId, epId int64) error {
@@ -257,7 +287,322 @@ func (s *dramaService) buildScriptGenUserInput(d *entity.Drama, episodeTitle, de
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("【要求】\n请根据以上剧情描述,为当前剧集创作一份详细的剧本。剧情描述是核心创作依据,请严格围绕其内容展开。\n\n输出格式:JSON数组,每个元素是一个镜头对象(shot),包含以下字段:\n- index: 镜头序号(从1开始)\n- startTime: 开始时间(格式MM:SS\n- endTime: 结束时间(格式MM:SS\n- event: 事件描述(该镜头中发生的情节概括)\n- cameraMovement: 运镜方式,从以下标准类型中选择一种:固定镜头、推、拉、摇、移、跟、升、降、旋转、晃动、航拍\n- characters: 出演人物数组,填写演员名称,如[\"张三\", \"李四\"],从「可用演员」中选择\n- scene: 场景名称,从「可用场景」中选择\n- props: 道具数组,填写道具名称,如[\"剑\", \"酒杯\"],从「可用道具」中选择\n\n直接输出JSON数组,不要markdown代码块标记,不要其他任何内容。\n\n要求:\n1. 剧本必须紧扣剧情描述展开\n2. 所有镜头时长之和应等于 %d 秒(每个镜头5-15秒不等)\n3. 优先使用提供的演员、场景和道具,如需新增请合理创作\n4. 每个镜头的characters、scene、props字段必须从提供的可用列表中选取名称,确保与参考素材一致\n5. 对话自然流畅,情节有起伏\n6. 镜头数量建议6-15个\n", d.EpisodeDuration))
b.WriteString(fmt.Sprintf("【要求】\n请根据以上剧情描述,为当前剧集创作一份详细的剧本。剧情描述是核心创作依据,请严格围绕其内容展开。\n\n输出格式:JSON数组,每个元素是一个镜头对象(shot),包含以下字段:\n- index: 镜头序号(从1开始)\n- startTime: 开始时间(格式MM:SS\n- endTime: 结束时间(格式MM:SS\n- event: 事件描述(该镜头中发生的情节概括)\n- cameraMovement: 运镜方式,从以下标准类型中选择一种:固定镜头、推、拉、摇、移、跟、升、降、旋转、晃动、航拍\n- characters: 出演人物数组,填写演员名称,如[\"张三\", \"李四\"],从「可用演员」中选择\n- scene: 场景名称,从「可用场景」中选择\n- props: 道具名称数组,如[\"剑\", \"酒杯\"],从「可用道具」中选择\n\n直接输出JSON数组,不要markdown代码块标记,不要其他任何内容。\n\n要求:\n1. 剧本必须紧扣剧情描述展开\n2. 所有镜头时长之和应等于 %d 秒(每个镜头%d-%d秒不等)\n3. 优先使用提供的演员、场景和道具,如需新增请合理创作\n4. 每个镜头的characters、scene、props字段必须从提供的可用列表中选取名称,确保与参考素材一致\n5. 对话自然流畅,情节有起伏\n6. 镜头数量建议6-15个\n", d.EpisodeDuration, d.MinShotDuration, d.MaxShotDuration))
return b.String()
}
// createPendingTasks 为剧集创建待生成任务(按 calcSegDurs 拆段),每段脚本只包含本段对应部分
func createPendingTasks(ctx context.Context, dramaId, epId int64, script string, tx gdb.TX) error {
d, err := dao.Drama.GetOne(ctx, dramaId)
if err != nil || d == nil {
return fmt.Errorf("短剧不存在: %d", dramaId)
}
modelCfg := ConfigService.Get(ctx)
// 预加载参考素材(演员优先,场景其次,道具最后)
chars, _, _ := dao.Character.ListPageByDrama(ctx, dramaId, 1, -1)
scenes, _ := dao.Scene.ListByDrama(ctx, dramaId)
props, _ := dao.Prop.ListByDrama(ctx, dramaId)
// 读取反向提示词
negativePrompt := ""
if data, err := os.ReadFile("negative_prompt.md"); err == nil {
negativePrompt = strings.TrimSpace(string(data))
}
// 从 video_schema 读取参考素材上限和默认参数
refMax := 5
var vs map[string]any
if modelCfg.VideoSchema != "" {
json.Unmarshal([]byte(modelCfg.VideoSchema), &vs)
}
if vs != nil {
if v := nested(vs, "input", "reference_urls", "total_max"); v != nil {
if n, ok := v.(float64); ok {
refMax = int(n)
}
}
}
// 从 video_schema 读取 prompt 最大字符数
promptMaxChars := intVal(nested(vs, "input", "prompt", "max_chars"), 0)
// 构建参考列表:演员 base64 → 场景图片 base64 → 道具图片 base64(与提交视频时顺序一致)
type namedRef struct {
name string
url string
}
var namedRefs []namedRef
for _, ch := range chars {
if len(namedRefs) >= refMax {
break
}
if ch.PortraitPath != "" {
namedRefs = append(namedRefs, namedRef{name: ch.Name, url: ch.PortraitPath})
}
}
for _, sc := range scenes {
if len(namedRefs) >= refMax {
break
}
if sc.ImagePath != "" {
namedRefs = append(namedRefs, namedRef{name: sc.Name, url: sc.ImagePath})
}
}
for _, p := range props {
if len(namedRefs) >= refMax {
break
}
if p.ImagePath != "" {
namedRefs = append(namedRefs, namedRef{name: p.Name, url: p.ImagePath})
}
}
// 读取拆段时长约束(与 calcSegDurs 逻辑一致)
effectiveMax := 15
minSingle := 5
if vs != nil {
if v := intVal(nested(vs, "parameters", "duration", "max"), 0); v > 0 {
effectiveMax = v
}
if v := intVal(nested(vs, "parameters", "duration", "min"), 0); v > 0 {
minSingle = v
}
}
if minSingle > effectiveMax {
minSingle = effectiveMax
}
// 从 video_schema 读取默认 parameters(含 duration/audio/watermark 等)
params := map[string]any{}
if vs != nil {
if paramsDef := nested(vs, "parameters"); paramsDef != nil {
if pm, ok := paramsDef.(map[string]any); ok {
for k, v := range pm {
paramDef, ok := v.(map[string]any)
if !ok {
continue
}
// 如果有 supported_models 且不包含当前模型,跳过
if sm, ok := paramDef["supported_models"]; ok {
if models, ok := sm.([]any); ok {
found := false
for _, m := range models {
if ms, ok := m.(string); ok && ms == modelCfg.VideoModelName {
found = true
break
}
}
if !found {
continue
}
}
}
if def, ok := paramDef["default"]; ok {
params[k] = def
}
}
}
}
// 尺寸映射:从 drama 的 Resolution/AspectRatio 查 video_schema
if sizes := nested(vs, "parameters", "size", "sizes"); sizes != nil {
if sm, ok := sizes.(map[string]any); ok {
if v := resolveSizeFromSchema(sm, d.Resolution, d.AspectRatio); v != "" {
params["size"] = v
}
}
}
}
// 预构建名称替换信息(characterN 引用)
type _nameRep struct{ name, label string }
var _reps []_nameRep
if len(namedRefs) > 0 {
for j, nr := range namedRefs {
_reps = append(_reps, _nameRep{nr.name, fmt.Sprintf("character%d", j+1)})
}
sort.Slice(_reps, func(a, b int) bool { return len(_reps[a].name) > len(_reps[b].name) })
}
var _refStr string
if len(_reps) > 0 {
var _parts []string
for _, r := range _reps {
_parts = append(_parts, fmt.Sprintf("%s=%s", r.label, r.name))
}
_refStr = fmt.Sprintf("。角色引用说明:%s", strings.Join(_parts, "、"))
}
_refLen := len([]rune(_refStr))
// buildFinalPrompt 执行名称替换并追加引用说明
buildFinalPrompt := func(raw string) string {
for _, r := range _reps {
raw = strings.ReplaceAll(raw, r.name, r.label)
}
return raw + _refStr
}
// refURLs 在所有段中相同
refURLs := make([]string, len(namedRefs))
for j, nr := range namedRefs {
refURLs[j] = nr.url
}
// ============ 按脚本类型分组 ============
type _taskGroup struct {
promptText string
dur int
}
var taskGroups []_taskGroup
if domain.IsShotsJSON(script) {
// JSON 镜头数组:按镜头分组,累加连续镜头直到触及时长或字符上限
var allShots []domain.Shot
if err := json.Unmarshal([]byte(script), &allShots); err != nil || len(allShots) == 0 {
return fmt.Errorf("解析镜头脚本失败: %w", err)
}
type _shotGroup struct {
shots []domain.Shot
totalDur int
}
var cur _shotGroup
for _, sh := range allShots {
shDur := parseMMSSToSeconds(sh.EndTime) - parseMMSSToSeconds(sh.StartTime)
if shDur <= 0 {
shDur = 1
}
// 计算加入当前镜头后的候选组总时长和提示词长度
candShots := make([]domain.Shot, len(cur.shots)+1)
copy(candShots, cur.shots)
candShots[len(cur.shots)] = sh
candDur := cur.totalDur + shDur
candPrompt := buildFinalPrompt(domain.ShotsToText(candShots))
exceedDur := len(cur.shots) > 0 && candDur > effectiveMax
exceedChars := promptMaxChars > 0 && len([]rune(candPrompt)) > promptMaxChars
if exceedDur || exceedChars {
// 当前组已满,保存并开启新组
taskGroups = append(taskGroups, _taskGroup{
promptText: buildFinalPrompt(domain.ShotsToText(cur.shots)),
dur: cur.totalDur,
})
cur = _shotGroup{shots: []domain.Shot{sh}, totalDur: shDur}
} else {
cur.shots = candShots
cur.totalDur = candDur
}
}
if len(cur.shots) > 0 {
taskGroups = append(taskGroups, _taskGroup{
promptText: buildFinalPrompt(domain.ShotsToText(cur.shots)),
dur: cur.totalDur,
})
}
} else {
// 纯文本脚本:按 calcSegDurs 拆段 + splitScriptForSegment 切分(保留原逻辑)
segDurs := calcSegDurs(d.EpisodeDuration, modelCfg)
segStartTimes := make([]int, len(segDurs))
for i := 1; i < len(segDurs); i++ {
segStartTimes[i] = segStartTimes[i-1] + segDurs[i-1]
}
totalDur := d.EpisodeDuration
if promptMaxChars > 0 {
totalRunes := len([]rune(script))
if totalRunes > 0 && _refLen < promptMaxChars {
charsPerSeg := promptMaxChars - _refLen
maxDurByChars := charsPerSeg * int(totalDur) / totalRunes
if maxDurByChars > 0 && maxDurByChars < effectiveMax {
if maxDurByChars < minSingle {
maxDurByChars = minSingle
}
segDurs = calcSegmentDurations(int(totalDur), maxDurByChars, minSingle)
segStartTimes = make([]int, len(segDurs))
for i := 1; i < len(segDurs); i++ {
segStartTimes[i] = segStartTimes[i-1] + segDurs[i-1]
}
}
}
}
for i, segDur := range segDurs {
segScript := splitScriptForSegment(script, segStartTimes[i], segDur, int(totalDur))
taskGroups = append(taskGroups, _taskGroup{
promptText: buildFinalPrompt(segScript),
dur: segDur,
})
}
}
// ============ 插入任务 ============
for i, tg := range taskGroups {
segParams := make(map[string]any, len(params)+1)
for k, v := range params {
segParams[k] = v
}
segParams["duration"] = tg.dur
bodyJSON, _ := json.Marshal(map[string]any{
"model": modelCfg.VideoModelName,
"input": map[string]any{
"prompt": tg.promptText,
"negative_prompt": negativePrompt,
"reference_urls": refURLs,
},
"parameters": segParams,
})
_, err := tx.Model(public.TableNameGenerationTask).Ctx(ctx).Data(g.Map{
"drama_id": dramaId,
"episode_id": epId,
"segment_idx": i,
"status": consts.TaskStatusPending,
"script": string(bodyJSON),
"duration": tg.dur,
"num_segments": len(taskGroups),
}).Insert()
if err != nil {
return fmt.Errorf("创建第%d段待生成任务失败: %w", i+1, err)
}
}
return nil
}
// splitScriptForSegment 将整集脚本按时间范围切分为对应段落的脚本内容
func splitScriptForSegment(fullScript string, segStartTime, segDur, totalDur int) string {
if domain.IsShotsJSON(fullScript) {
var shots []domain.Shot
if err := json.Unmarshal([]byte(fullScript), &shots); err != nil {
return fullScript
}
segEndTime := segStartTime + segDur
var filtered []domain.Shot
for _, s := range shots {
shStart := parseMMSSToSeconds(s.StartTime)
shEnd := parseMMSSToSeconds(s.EndTime)
if shEnd <= segStartTime || shStart >= segEndTime {
continue
}
filtered = append(filtered, s)
}
if len(filtered) > 0 {
return domain.ShotsToText(filtered)
}
return ""
}
// 纯文本脚本:按时长比例切分字符
runes := []rune(fullScript)
if len(runes) == 0 || totalDur <= 0 {
return fullScript
}
startRune := len(runes) * segStartTime / totalDur
endRune := len(runes) * (segStartTime + segDur) / totalDur
if startRune >= len(runes) {
return ""
}
if endRune > len(runes) {
endRune = len(runes)
}
return string(runes[startRune:endRune])
}
+17 -10
View File
@@ -20,12 +20,13 @@ type OrderedRef struct {
Index int // 在 OrderedRefs 中的位置
}
// GenerationContext 一次生成会话的上下文,预加载当前短剧的演员/场景/道具数据
// GenerationContext 一次生成会话的上下文,预加载当前短剧的演员/场景/道具/背景音乐数据
type GenerationContext struct {
Drama *entity.Drama
Characters []*entity.Character
Scenes []*entity.Scene
Props []*entity.Prop
Drama *entity.Drama
Characters []*entity.Character
Scenes []*entity.Scene
Props []*entity.Prop
BackgroundMusic []*entity.BackgroundMusic
// RefIndex 按名称索引,用于 Agent 输出匹配
RefIndex RefIndex
@@ -51,12 +52,18 @@ func BuildGenerationContext(ctx context.Context, drama *entity.Drama) (*Generati
return nil, fmt.Errorf("加载道具失败: %w", err)
}
bgmList, err := dao.BackgroundMusic.ListByDrama(ctx, drama.Id)
if err != nil {
return nil, fmt.Errorf("加载背景音失败: %w", err)
}
ctx2 := &GenerationContext{
Drama: drama,
Characters: characters,
Scenes: scenes,
Props: props,
RefIndex: make(RefIndex),
Drama: drama,
Characters: characters,
Scenes: scenes,
Props: props,
BackgroundMusic: bgmList,
RefIndex: make(RefIndex),
}
// 构建 RefIndex
+1
View File
@@ -0,0 +1 @@
{"model": "wan2.6-r2v", "input": {"prompt": {"required": true, "type": "string", "max_chars": 1500, "description": "文本提示词。支持中英文,每个汉字/字母/标点占一个字符,超过部分自动截断。通过 character1、character2 引用参考角色,每个参考仅包含单一角色。模型仅通过此方式识别参考中的角色"}, "negative_prompt": {"required": false, "type": "string", "max_chars": 500, "description": "反向提示词,用来描述不希望在视频画面中出现的内容,可以对视频画面进行限制"}, "reference_urls": {"required": true, "type": "array", "items": "string", "ref_ordering": "按数组顺序定义角色顺序,第1个URL对应character1,第2个对应character2,依此类推。每个参考文件仅包含一个主体角色", "total_max": 5, "image_max": 5, "video_max": 3, "video_formats": ["MP4", "MOV"], "video_duration_sec": {"min": 1, "max": 30}, "video_max_size_mb": 100, "image_formats": ["JPEG", "JPG", "PNG", "BMP", "WEBP"], "image_note": "PNG不支持透明通道", "image_resolution_px": {"min": 240, "max": 8000}, "image_max_size_mb": 20}}, "parameters": {"size": {"required": false, "type": "string", "format": "{width}*{height}", "default": "1920*1080", "description": "视频分辨率,格式为宽*高。必须设置为具体数值(如1280*720),而不是1:1或720P", "sizes": {"720P": {"9:16": "720*1280", "16:9": "1280*720", "1:1": "960*960", "4:3": "1088*832", "3:4": "832*1088"}, "1080P": {"9:16": "1080*1920", "16:9": "1920*1080", "1:1": "1440*1440", "4:3": "1632*1248", "3:4": "1248*1632"}}}, "duration": {"required": false, "type": "integer", "min": 2, "max": 10, "default": 5, "description": "生成视频的时长,单位为秒"}, "shot_type": {"required": false, "type": "string", "enum": ["single", "multi"], "default": "single", "description": "镜头类型。参数优先级:shot_type > prompt。single=单镜头视频,multi=多镜头视频"}, "audio": {"required": false, "type": "boolean", "default": true, "supported_models": ["wan2.6-r2v-flash"], "description": "是否生成有声视频"}, "watermark": {"required": false, "type": "boolean", "default": false}, "seed": {"required": false, "type": "integer", "min": 0, "max": 2147483647}}}
+263
View File
@@ -0,0 +1,263 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strconv"
"github.com/Eyevinn/mp4ff/mp4"
)
func main() {
if len(os.Args) < 4 {
fmt.Println("用法: split_merged <输入文件> <输出目录> <段数>")
os.Exit(1)
}
inputPath := os.Args[1]
outDir := os.Args[2]
numSegments, err := strconv.Atoi(os.Args[3])
if err != nil || numSegments <= 0 {
fmt.Printf("无效的段数: %s\n", os.Args[3])
os.Exit(1)
}
// 读取合并文件
f, err := mp4.ReadMP4File(inputPath)
if err != nil {
fmt.Printf("读取文件失败: %v\n", err)
os.Exit(1)
}
if err := os.MkdirAll(outDir, 0755); err != nil {
fmt.Printf("创建输出目录失败: %v\n", err)
os.Exit(1)
}
// 收集每轨道按段划分的 sample 范围
type trakInfo struct {
trak *mp4.TrakBox
samples uint32 // 总 sample 数
perSeg uint32 // 每段 sample 数
}
var traks []trakInfo
for _, trak := range f.Moov.Traks {
stbl := trak.Mdia.Minf.Stbl
if stbl.Stsz != nil {
totalSamples := stbl.Stsz.SampleNumber
perSeg := totalSamples / uint32(numSegments)
traks = append(traks, trakInfo{
trak: trak,
samples: totalSamples,
perSeg: perSeg,
})
fmt.Printf("轨道 %s: 总 %d samples, 每段 %d\n", trak.Mdia.Hdlr.HandlerType, totalSamples, perSeg)
}
}
if len(traks) == 0 {
fmt.Println("未找到可分割的轨道")
os.Exit(1)
}
// 逐段切分
for seg := 0; seg < numSegments; seg++ {
outPath := filepath.Join(outDir, fmt.Sprintf("seg_%d.mp4", seg))
fmt.Printf("切分第 %d 段 -> %s\n", seg, outPath)
outFile := mp4.NewFile()
outFile.Ftyp = f.Ftyp
outFile.Moov = f.Moov // 稍后会修改
// 为每轨道创建仅包含本段 samples 的 stbl
for _, ti := range traks {
stbl := ti.trak.Mdia.Minf.Stbl
startSample := seg * int(ti.perSeg)
endSample := startSample + int(ti.perSeg)
if seg == numSegments-1 {
endSample = int(ti.samples) // 最后一段拿剩余所有 samples
}
fmt.Printf(" 轨道 %s: samples [%d, %d)\n", ti.trak.Mdia.Hdlr.HandlerType, startSample, endSample)
// 拷贝 stsz entry(只保留本段范围)
if stbl.Stsz != nil {
newStsz := &mp4.StszBox{}
if stbl.Stsz.SampleUniformSize > 0 {
newStsz.SampleUniformSize = stbl.Stsz.SampleUniformSize
newStsz.SampleNumber = uint32(endSample - startSample)
} else {
newStsz.SampleSize = make([]uint32, endSample-startSample)
copy(newStsz.SampleSize, stbl.Stsz.SampleSize[startSample:endSample])
newStsz.SampleNumber = uint32(len(newStsz.SampleSize))
}
stbl.Stsz = newStsz
}
// 计算本段 chunk 范围:通过 stsc 找到对应的 chunk
// 直接根据 perSeg chunks 计算。每段 chunks = totalChunks / numSegments
chunkCount := uint32(0)
if stbl.Stco != nil {
chunkCount = uint32(len(stbl.Stco.ChunkOffset))
} else if stbl.Co64 != nil {
chunkCount = uint32(len(stbl.Co64.ChunkOffset))
}
perSegChunks := chunkCount / uint32(numSegments)
startChunk := seg * int(perSegChunks)
endChunk := startChunk + int(perSegChunks)
if seg == numSegments-1 {
endChunk = int(chunkCount)
}
// 重新构建 stco(只保留本段 chunk)
if stbl.Stco != nil {
oldChunks := stbl.Stco.ChunkOffset
stbl.Stco.ChunkOffset = oldChunks[startChunk:endChunk]
// 调整偏移量:减去 mdat 中本段数据的起始位置
baseOffset := oldChunks[startChunk]
for i := range stbl.Stco.ChunkOffset {
stbl.Stco.ChunkOffset[i] -= baseOffset
}
}
if stbl.Co64 != nil {
oldChunks := stbl.Co64.ChunkOffset
stbl.Co64.ChunkOffset = oldChunks[startChunk:endChunk]
baseOffset := oldChunks[startChunk]
for i := range stbl.Co64.ChunkOffset {
stbl.Co64.ChunkOffset[i] -= baseOffset
}
}
// 重新构建 stsc(只保留本段 chunk 的 entries
if stbl.Stsc != nil {
var newEntries []mp4.StscEntry
for _, e := range stbl.Stsc.Entries {
if e.FirstChunk-1 >= uint32(startChunk) && e.FirstChunk-1 < uint32(endChunk) {
newEntries = append(newEntries, mp4.StscEntry{
FirstChunk: e.FirstChunk - uint32(startChunk),
SamplesPerChunk: e.SamplesPerChunk,
SampleDescriptionIndex: e.SampleDescriptionIndex,
})
}
}
stbl.Stsc.Entries = newEntries
}
// 重设 stts(重建,只保留本段 samples 对应的时间)
if stbl.Stts != nil {
newStts := &mp4.SttsBox{}
currentSample := uint32(0)
for i := range stbl.Stts.SampleCount {
count := stbl.Stts.SampleCount[i]
delta := stbl.Stts.SampleTimeDelta[i]
nextSample := currentSample + count
if nextSample <= uint32(startSample) {
currentSample = nextSample
continue
}
if currentSample >= uint32(endSample) {
break
}
overlapStart := uint32(0)
if currentSample < uint32(startSample) {
overlapStart = uint32(startSample) - currentSample
}
overlapEnd := count
if nextSample > uint32(endSample) {
overlapEnd = uint32(endSample) - currentSample
}
if overlapStart < overlapEnd {
newStts.SampleCount = append(newStts.SampleCount, overlapEnd-overlapStart)
newStts.SampleTimeDelta = append(newStts.SampleTimeDelta, delta)
}
currentSample = nextSample
}
stbl.Stts = newStts
}
// 修正 stco 偏移:加上 ftyp+moov+mdat_header 偏移
mdatDataStart := uint32(0) // 稍后在写入时修正
_ = mdatDataStart
}
// 提取本段 mdat 数据
mdatStart := uint32(0)
if len(traks) > 0 && traks[0].trak.Mdia.Minf.Stbl.Stco != nil {
// 使用第一个轨道的第一个 chunk 偏移量作为数据起始位置
mdatStart = traks[0].trak.Mdia.Minf.Stbl.Stco.ChunkOffset[0]
}
// 重构 mdat:从合并文件提取本段数据
outFile.Mdat = &mp4.MdatBox{}
mdatPayloadLen := uint64(0)
// 计算本段 mdat 数据长度
for _, ti := range traks {
stbl := ti.trak.Mdia.Minf.Stbl
if stbl.Stsz != nil {
if stbl.Stsz.SampleUniformSize > 0 {
mdatPayloadLen += uint64(stbl.Stsz.SampleUniformSize) * uint64(stbl.Stsz.SampleNumber)
} else {
for _, s := range stbl.Stsz.SampleSize {
mdatPayloadLen += uint64(s)
}
}
}
}
if mdatPayloadLen > 0 {
segData := make([]byte, mdatPayloadLen)
// 从原始 mdat 拷贝
offset := uint64(mdatStart)
_ = offset
copyPos := uint64(0)
for _, ti := range traks {
stbl := ti.trak.Mdia.Minf.Stbl
if stbl.Stsz != nil {
if stbl.Stsz.SampleUniformSize > 0 {
dataLen := uint64(stbl.Stsz.SampleUniformSize) * uint64(stbl.Stsz.SampleNumber)
copy(segData[copyPos:], f.Mdat.Data[offset:offset+dataLen])
copyPos += dataLen
offset += dataLen
} else {
for _, s := range stbl.Stsz.SampleSize {
copy(segData[copyPos:], f.Mdat.Data[offset:offset+uint64(s)])
copyPos += uint64(s)
offset += uint64(s)
}
}
}
}
outFile.Mdat.SetData(segData)
}
// 更新 mdhd/tkhd/mvhd 时长
outFile.Moov.Mvhd = f.Moov.Mvhd // 拷贝原始 mvhd
// 简化处理:直接使用原始 mvhd 时长
// 重建 Children
outFile.Children = []mp4.Box{outFile.Ftyp, outFile.Moov, outFile.Mdat}
// 修正 stco 偏移:调整到实际文件位置
combinedBase := outFile.Ftyp.Size() + outFile.Moov.Size() + outFile.Mdat.HeaderSize()
for _, ti := range traks {
stbl := ti.trak.Mdia.Minf.Stbl
if stbl.Stco != nil {
for i := range stbl.Stco.ChunkOffset {
stbl.Stco.ChunkOffset[i] += uint32(combinedBase)
}
}
}
if err := mp4.WriteToFile(outFile, outPath); err != nil {
fmt.Printf("写入 %s 失败: %v\n", outPath, err)
continue
}
fmt.Printf(" 完成: %s\n", outPath)
}
fmt.Println("切分完成")
}