1
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
@@ -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 可指向 一张图像 或 一段视频:
|
||||
//
|
||||
//图像数量:0~5。
|
||||
//
|
||||
//视频数量:0~3。
|
||||
//
|
||||
//总数限制:图像 + 视频 ≤ 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。
|
||||
//
|
||||
//时长:1s~30s。
|
||||
//
|
||||
//视频大小:不超过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*1080(1080P)。可选分辨率:720P、1080P对应的所有分辨率。
|
||||
//
|
||||
//wan2.6-r2v:默认值为 1920*1080(1080P)。可选分辨率:720P、1080P对应的所有分辨率。
|
||||
//
|
||||
//720P档位:可选的视频分辨率及其对应的视频宽高比为:
|
||||
//
|
||||
//1280*720:16:9。
|
||||
//
|
||||
//720*1280:9:16。
|
||||
//
|
||||
//960*960:1:1。
|
||||
//
|
||||
//1088*832:4:3。
|
||||
//
|
||||
//832*1088:3: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 |
@@ -0,0 +1 @@
|
||||
低分辨率、模糊、失真、扭曲、变形、闪烁、抖动、过度曝光、色彩失真、画面杂乱、构图不当、主体不完整、多余物体、残缺、面部扭曲、肢体不自然、比例失调、低质量、最差质量、不良渲染
|
||||
Binary file not shown.
@@ -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 call(arguments 为 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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:"视频生成任务回调地址"`
|
||||
}
|
||||
|
||||
@@ -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:"更新时间"`
|
||||
}
|
||||
|
||||
@@ -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:"创建时间"`
|
||||
|
||||
@@ -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"` // 参考素材 URL(base64 data URL 或 HTTP URL)
|
||||
MediaURL string `json:"mediaUrl"` // 参考素材 URL(API 要求 HTTPS 公网 URL 或 OSS URL)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+602
-369
File diff suppressed because it is too large
Load Diff
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}}
|
||||
@@ -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("切分完成")
|
||||
}
|
||||
Reference in New Issue
Block a user