Y 3 months ago
commit
afa8f591d3

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+test_output/
+*.pyc
+__pycache__/
+.env
+logs/

+ 0 - 0
README.md


+ 55 - 0
config/i2v_prompt.py

@@ -0,0 +1,55 @@
+PROMPT = """
+图生视频核心提示词撰写技巧
+​1. 动作指令​
+
+​基础动作​:清晰描述“主体+动作”,例如“女孩抱着狐狸”、“小猫打哈欠”。
+​多动作指令​:按照时间顺序描述连续动作,可实现单人物或多人物复杂互动。
+示例:“女子拿起酒杯,喝了一口后放下,然后起身离开。”
+示例:“主唱唱歌,吉他手弹吉他,贝斯手弹贝斯...”
+​2. 镜头语言​
+
+​基础运镜​:模型能精准响应专业的运镜指令。
+​推​:镜头靠近主体。
+​拉​:镜头远离主体。
+​摇​:镜头水平或垂直旋转。
+​移​:镜头横向移动。
+​环绕​:镜头围绕主体旋转。
+​跟随​:镜头跟随主体运动。
+​升/降​:镜头垂直移动。
+​变焦​:改变镜头焦距。
+​复杂运镜​:将多个运镜指令组合,构建富有创意的长镜头。
+示例:“镜头从地面跟随小狗...向上摇摄...围绕旋转...最后拉近定格。”
+​景别和视角控制​:使用专业术语控制画面构图。
+​景别​:远景、全景、中景、近景、特写。
+​视角​:水下镜头、航拍、俯拍、仰拍、微距摄影、过肩镜头等。
+​3. 风格控制​
+
+​多风格直出​:直接在提示词中指定风格,模型能生成多种2D/3D风格。
+示例风格:体素、像素、毛毡、粘土、插画、3D动画、日本漫画、美漫、黑白线稿等。
+​4. 画面美感控制​
+
+​人物外形​:通过精细化描述控制人物的长相、穿着、神态等细节。
+示例:“脸型微胖、三白眼、眼角有痣、皮肤粗糙”。
+​画面美感与氛围​:
+​指定视频类型​:如“欧洲文艺电影”、“复古香港电影”、“恐怖片”来引导整体质感。
+​自然语言描述氛围​:使用如“油画般的”、“有质感的老电影”、“略显古早,妆造廉价”等词语控制画面情绪和美学。
+​5. 多镜头叙事能力​
+
+这是 Seedance-1.0-pro 的核心能力。可以在一个提示词中描述多个镜头,通过“镜头切换”来连接。
+每次切换后,需详细描述新镜头的人物、场景和动作,模型会尽力保持主体和风格的连续性。
+示例:“中近景拍摄男子打哈欠。​镜头切换,女人拿相机拍摄男性。​镜头切换,俯拍桌面杂志...”
+​6. 创意特效​
+
+鼓励发挥想象力,描述超现实或特效场景,模型本身具备实现多种创意效果的能力。
+示例:“自由女神像像火箭一样升空”、“牛蛙瘫在按摩椅上,白猫为其踩奶”、“男孩生气后全身爆炸”、“男孩看书瞬间变老”。
+技术参数控制
+在提示词文本后,可以追加 --[parameters] 来控制视频输出规格:
+
+--rs 或 --resolution:分辨率(如 720p)。
+--dur 或 --duration:视频时长(秒)。
+--cf 或 --camerafixed:是否固定摄像头(true/false)。
+画幅支持
+模型支持多种视频比例:​1:1, 3:4, 4:3, 16:9, 9:16, 21:9。对于图生视频,建议使用这些比例的图片作为参考。
+
+​总结​:要充分利用 Seedance-1.0-pro,关键在于像导演一样思考,使用精确、连续、富有画面感的语言,将动作、运镜、风格、景别、氛围以及多镜头切换清晰地组合在提示词中。
+"""

+ 13 - 0
config/idea2video.yaml

@@ -0,0 +1,13 @@
+chat_model:
+  init_args:
+    model: doubao-seed-1-6-250615
+    model_provider: ark
+    api_key: "33f28b34-65dc-489c-b825-d6e1aa59fc94"
+    base_url: https://ark.cn-beijing.volces.com/api/v3
+    temperature: 0.7
+    max_tokens: 50000
+
+  # Rate limits for chat model API calls
+  # Set to null to disable rate limiting for this service
+  max_requests_per_minute: 500
+  max_requests_per_day: 2000

+ 141 - 0
config/prompts.py

@@ -0,0 +1,141 @@
+# 媒体理解提示词配置(Python格式)
+
+# 默认配置
+VIDEO_SCRIPT  = """
+## 你是一个"视频创作导演兼全AI链路视频脚本制作工程师",需严格按照以下规则创作输出脚本,服务"参考图生分镜→分镜生视频片段→后期拼接"全流程:
+## 输出格式:必须按照以下JSON格式进行输出:
+```json
+{
+    "basic_info": {
+        "script_theme": // 脚本主题,明确整体场景基调,
+        "total_lenses": // 总镜头数,方便后期拼接核对,
+        "total_duration": // 总时长,控制整体节奏,
+        "unified_style": // 全片统一风格/氛围,保证AI生成一致性
+    },
+    "lens_details": [
+        {
+            "lens_id": // 镜号:唯一标识,对应分镜/视频片段,以阿拉伯数字表示,
+            "lens_params": // 核心镜头参数:景别(远景/全景/中景/近景/特写等景别)+ 视角(俯拍/仰拍/过肩/平视/微距/等视角) + 运镜(推/拉/摇/移/跟随/环绕等运镜),
+            "core_vision": // 核心视觉画面:人物信息(如人物神情/姿态/动作,但是避免对人物的样貌和穿着进行描写;如:‘美女正在散步,非常开心的神态’)+ 场景细节;或景物描写,
+            "lines_narration": // 台词/旁白,可为None,
+            "bgm_style": // BGM风格,如:轻柔的小提琴独奏,节奏缓慢,旋律偏温暖,可为None,
+            "sound_effects": // 音效,如:树叶飘落的轻微沙沙声,可为None
+            "lens_duration": // 单镜头时长:5-12秒,适配图生视频模型,以阿拉伯数表示,
+        },
+        {
+            ... // 其他镜头信息
+        }
+    ]
+}
+```
+
+## 内容约束:生成的脚本需要符合我提供的视频主题,或参考我提供的优秀视频脚本进行模仿创作;确保脚本整体内容的自然与连贯性,A-Roll与B-Roll兼具。
+## 要求:请再读一遍任务和所有约束条件后,开始创作脚本。
+"""
+
+VIDEO_PROMPT = """
+## 你是一个专业的图生视频提示词优化工程师,专注于将用户输入的原始提示词打磨为更符合图生视频模型生成逻辑、能显著提升视频质量的专业提示词。
+## 你的优化需遵循以下图生视频模型提示词指南:
+```图生视频模型提示词指南
+​1. 动作指令​
+- ​基础动作​:清晰描述“主体+动作”,例如“女孩抱着狐狸”、“小猫打哈欠”。
+- ​多动作指令​:按照时间顺序描述连续动作,可实现单人物或多人物复杂互动。
+- 示例:“女子拿起酒杯,喝了一口后放下,然后起身离开。”
+- 示例:“主唱唱歌,吉他手弹吉他,贝斯手弹贝斯...”
+
+​2. 镜头语言​
+​- 基础运镜​:模型能精准响应专业的运镜指令。
+- ​推​:镜头靠近主体。
+- ​拉​:镜头远离主体。
+- ​摇​:镜头水平或垂直旋转。
+- ​移​:镜头横向移动。
+- ​环绕​:镜头围绕主体旋转。
+- ​跟随​:镜头跟随主体运动。
+- ​升/降​:镜头垂直移动。
+- ​变焦​:改变镜头焦距。
+- ​复杂运镜​:将多个运镜指令组合,构建富有创意的长镜头。
+- 示例:“镜头从地面跟随小狗...向上摇摄...围绕旋转...最后拉近定格。”
+- ​景别和视角控制​:使用专业术语控制画面构图。
+- ​景别​:远景、全景、中景、近景、特写。
+- ​视角​:水下镜头、航拍、俯拍、仰拍、微距摄影、过肩镜头等。
+
+​3. 风格控制​
+​- 多风格直出​:直接在提示词中指定风格,模型能生成多种2D/3D风格。
+- 示例风格:体素、像素、毛毡、粘土、插画、3D动画、日本漫画、美漫、黑白线稿等。
+
+​4. 画面美感控制​
+​- 人物外形​:通过精细化描述控制人物的情绪/神态/姿态/动作等细节,但避免对人物的外貌和穿着进行描写。
+- 示例:“美女非常开心地微笑,露出深深的小酒窝”。
+- ​画面美感与氛围​:
+- ​指定视频类型​:如“欧洲文艺电影”、“复古香港电影”、“恐怖片”来引导整体质感。
+- ​自然语言描述氛围​:使用如“油画般的”、“有质感的老电影”、“略显古早,妆造廉价”等词语控制画面情绪和美学。
+
+​5. 多镜头叙事能力​
+- 可以在一个提示词中描述多个镜头,通过“镜头切换”来连接。
+- 每次切换后,需详细描述新镜头的人物、场景和动作,模型会尽力保持主体和风格的连续性。
+- 示例:“中近景拍摄男子打哈欠。​镜头切换,女人拿相机拍摄男性。​镜头切换,俯拍桌面杂志...”
+
+​6. 创意特效​
+- 鼓励发挥想象力,描述超现实或特效场景,模型本身具备实现多种创意效果的能力。
+- 示例:“自由女神像像火箭一样升空”、“牛蛙瘫在按摩椅上,白猫为其踩奶”、“男孩生气后全身爆炸”、“男孩看书瞬间变老”。
+
+​总结​:撰写优秀提示词的关键在于像导演一样思考,使用精确、连续、富有画面感的语言,将动作、运镜、风格、景别、氛围以及多镜头切换清晰地组合在提示词中。
+```
+
+## 要求:
+- 优化后提示词不要出现‘主体’这个词,而是替换为具体的人物或事物,如:美女、女孩、中年男性、帆船、手表、挂在衣帽架上的连衣裙等等,而非用‘主体’这个抽象且泛泛的词。
+- 以自然语言形式输出优化后的提示词即可,字数控制在30-150字左右。
+- 请再读一遍任务和所有约束条件后,开始提示词优化。
+"""
+
+# 故事创作与角色创作提示词配置
+VIDEO_STORY = """
+**任务指令**
+- 你是一位资深故事与角色创作专家。你的使命是:在绝对自由的想象疆域中,构建深刻、独特、不可预测的故事与人物;同时,将成果精准封装于指定的结构化格式之中。
+
+**任务详情**
+你具备以下特质:
+- 性格特征:创作力、洞察力、同理心、逻辑性
+- 专业技能:构思、叙事、角色塑造、主题挖掘、冲突设计、世界观构建
+- 表达风格:生动、深刻、连贯、引人入胜
+- 角色创作路径:从核心问题触发,识别人物缺口,经历关键选择,最终展现人物弧光
+- 核心理念:故事由角色驱动,强调“故事存在于角色之中”
+
+**Output Format**(必须严格遵守)*
+```json
+{
+    "role": [
+        {
+            "role_id": // 角色ID,例如:role_1,
+            "name": // 角色姓名,
+            "age": // 角色年龄,
+            "gender": // 角色性别,
+            "personality": // 角色性格特征,
+            "background": // 角色背景故事
+        },
+        // 可添加更多角色
+    ],
+    "story": [
+        {
+            "subtitle": // 章节标题,
+            "content": // 章节内容,字数控制在200字以内,
+        },
+        // 可添加更多章节,章节数量不得超过10个
+    ],
+    "creative_process": [
+        {
+            "point": // 影响创作的第一重要因素,
+        },
+        {
+            "point": // 其次的重要因素,
+        }
+        // 根据重要性依次列出
+    ]
+}
+```
+
+**Tips**
+- 输出仅限于合法的JSON对象,避免包含任何额外文本、注释、说明或 Markdown。
+- 记住:JSON 是牢笼,也是舞台——你要在铁栏之内,跳一支无人见过的舞。
+- 在开始任务之前,请仔细阅读上述所有指导说明。
+"""

+ 133 - 0
config/prompts.yaml

@@ -0,0 +1,133 @@
+# 媒体理解提示词配置
+
+defaults:
+  video:
+    caption: "视频里有什么?"
+    scene: "这是什么场景?"
+    action: "视频中发生了什么动作?"
+    emotion: "视频中人物的情绪如何?"
+  
+  image:
+    caption: "请描述图片内容"
+    scene: "这是什么场景?"
+    object: "图片中有哪些主要物体?"
+    emotion: "图片传达了什么情绪?"
+    
+  text:
+    summary: "请总结这段文本的主要内容"
+    sentiment: "这段文本表达了什么情感?"
+    keywords: "这段文本的关键词是什么?"
+    
+    topic: "## 对用户指定的电影的主题进行深度概括,以帮助后续电影解说视频的剪辑工作
+    ## 以JSON格式输出:{'电影主题': '主题描述'}"
+
+    story_line: "## 对用户指定的电影进行剧情解读,以帮助后续电影解说视频的剪辑工作
+    ## 按照剧情发展的时间顺序,对剧情进行完整详细解读,并给出剧情发展的关键节点,需要有合适的换行符让输出更清晰
+    ## 以JSON格式输出,key为剧情发展时间,value为剧情发展描述,需要有合适的换行符让输出更清晰
+    ## 输出格式:{'剧情关键节点1': '剧情发展描述1', '剧情关键节点2': '剧情发展描述2', '剧情关键节点3': '剧情发展描述3'}"
+
+    character: "## 对用户指定的电影进行人物关系的结构化解读,以帮助后续电影解说视频的剪辑工作
+    ## 以json格式返回,key为人物名称,value为人物关系,需要有合适的换行符让输出更清晰
+    ## 要求:按照人物关系对剧情发展的重要性排序,重要性高的排在前面
+    ## 输出格式:{'人物名称': {'人物名称1': '人物关系描述1(关系剧情发展)', '人物名称2': '人物关系描述2(关系剧情发展)', '人物名称3': '人物关系描述3(关系剧情发展)'}}"
+
+# 特定场景配置
+scenarios:
+  news:
+    video:
+      caption: "这段新闻视频报道了什么内容?"
+      focus: "新闻的主要焦点是什么?"
+    image:
+      caption: "这张新闻图片展示了什么?"
+      focus: "图片想要传达什么新闻信息?"
+    text:
+      summary: "这篇新闻报道的主要内容是什么?"
+      focus: "新闻的核心信息是什么?"
+      source: "信息来源是什么?"
+      impact: "这个新闻可能产生什么影响?"
+  
+  entertainment:
+    video:
+      caption: "这段娱乐视频的内容是什么?"
+      highlight: "有什么精彩的亮点?"
+    image:
+      caption: "这张娱乐图片展示了什么内容?"
+      highlight: "图片中的亮点是什么?"
+    text:
+      summary: "这篇娱乐新闻的内容是什么?"
+      highlight: "有什么亮点或爆点?"
+      celebrity: "涉及哪些名人?"
+      
+  academic:
+    text:
+      summary: "这篇学术文章的主要观点是什么?"
+      methodology: "使用了什么研究方法?"
+      findings: "主要研究发现是什么?"
+      contribution: "对该领域有什么贡献?"
+      
+  technical:
+    text:
+      summary: "这段技术文档描述了什么?"
+      usage: "如何使用这个技术或功能?"
+      requirements: "有什么技术要求或依赖?"
+      limitations: "有什么限制或注意事项?" 
+
+  movie:
+      video:
+        caption: "视频里有什么?"
+        scene: "这是什么场景?"
+        action: "视频中发生了什么动作?"
+        emotion: "视频中人物的情绪如何?"
+        story_line: "对整个视频的制作流程进行解析,梳理出整个视频的脚本。"  
+        jieshuo_video: "## 请在理解该电影片段的基础上,以木鱼水心风格生成该电影片段的解说文本
+        ## 仅以自然语言形式输出解说文本,不需要输出其他内容,解说文本字数需要与视频片段时长相匹配,但不能超过200字"
+
+        video_script: "## 你现在是'全AI链路视频脚本生成器',需严格按以下规则创作输出脚本,服务'图生图分镜制作->分镜生视频片段->后期拼接'全流程:
+        ## 1. 输出格式:必须以JSON格式输出,一级字段包括:basic_info 和 lens_details;其中basic_info字段包括二级字段:script_title、total_lenses, total_duration、unified_style;lens_details字段为分镜详情,包括二级字段:lens_id、lens_params、core_vision、、shot_description、camera_angle、camera_movement、lighting、props、cast、dialogue"
+        
+      image:
+        caption: "请描述图片内容"
+        scene: "这是什么场景?"
+        object: "图片中有哪些主要物体?"
+        emotion: "图片传达了什么情绪?"
+        
+      text:
+        summary: "请总结这段文本的主要内容"
+        sentiment: "这段文本表达了什么情感?"
+        keywords: "这段文本的关键词是什么?"
+
+        merge_lines: "判断台词列表(最多10句台词)中哪些台词是完整的人物对话,对完整的人物对话进行合并,并以JSON格式返回合并后的台词列表
+        ## 以JSON格式输出,例如:{'merged_lines': [[0,1,2], [3,4,5], [6,7,8], [9]]};其中[0,1,2]表示这三句台词是完整的人物对话,[3,4,5]表示这三句台词是完整的人物对话,[6,7,8]表示这三句台词是完整的人物对话,[9]表示这一句台词是完整的人物对话
+        ## 台词列表索引从0开始,除输出JSON格式结果外,不需要输出其他内容"
+        
+        topic: "## 对用户指定的电影的主题进行深度概括,以帮助后续电影解说视频的剪辑工作
+        ## 以JSON格式输出:{'电影主题': '主题描述'}"
+
+        story_line: "## 对用户指定的电影进行剧情解读,以帮助后续电影解说视频的剪辑工作
+        ## 按照剧情发展的时间顺序,对剧情进行完整详细解读,并给出剧情发展的关键节点,需要有合适的换行符让输出更清晰
+        ## 以JSON格式输出,key为剧情发展时间,value为剧情发展描述,需要有合适的换行符让输出更清晰
+        ## 输出格式:[{'剧情关键节点1': '剧情发展描述1'}, {'剧情关键节点2': '剧情发展描述2'}, {'剧情关键节点3': '剧情发展描述3'}]"
+
+        character: "## 对用户指定的电影进行人物关系的结构化解读,以帮助后续电影解说视频的剪辑工作
+        ## 以json格式返回,key为人物名称,value为人物关系,需要有合适的换行符让输出更清晰
+        ## 要求:按照人物关系对剧情发展的重要性排序,重要性高的排在前面
+        ## 输出格式:{'人物名称': {'人物名称1': '人物关系描述1(关系剧情发展)', '人物名称2': '人物关系描述2(关系剧情发展)', '人物名称3': '人物关系描述3(关系剧情发展)'}}"
+
+        genre_classify: "## 对每一个场景片段进行剧情归类
+        ## 输出格式:只需要输出场景片段所属剧情的序号,不需要输出任何其他内容,如:0(序号从0开始)"
+
+        generate_script: "## 对用户指定的电影进行解说脚本生成,以帮助后续电影解说视频的剪辑工作"
+
+        optimize_script: "## 请对输入的电影片段脚本进行充分理解,在此基础上构建一个更优秀的解说脚本,避免剧情解说的冗余与跳脱;请以JSON格式输出需保留片段的subclip_id
+        ## 输出格式:{'subclip_id': ['xxx', 'xxx', ...]}
+        ## 注意输出格式这是用于说明输出的格式规范,实际输出内容需要根据任务要求来获得
+        ## 切忌照搬输出格式案例进行输出"
+
+        optimize_commentary: "## 请以B站视频解说UP主木鱼水心的解说风格对输入的解说词进行改写,并返回改写后的结果。
+        ## 以自然语言形式输出结果,不要有任何多余的内容,请勿添加任何注释。字数控制在200字内。不能进行换行"
+
+        start: "## 请先构思该视频解说的大概一分钟时长的开头部分的台词(script),并从以上片段中挑选出与之对应的最适合开头的视频片段(输出subclip_id即可,需要根据台词时长,选择合适数量的视频片段)
+        ## 以JSON格式输出,仅包含'script'和'subclip_id'两个字段,其中'subclip_id'的值是一个列表"
+
+        end: "## 请先构思该视频解说的大概三分钟时长的结尾升华部分的台词,并从以上片段中挑选出与之对应的最适合做结尾升华的视频片段(输出subclip_id即可,需要根据台词时长,选择合适数量的视频片段)
+        ## 以JSON格式输出,仅包含'script'和'subclip_id'两个字段,其中'subclip_id'的值是一个列表"

BIN
data/front_portrait_0.png


+ 65 - 0
interfaces/image_output.py

@@ -0,0 +1,65 @@
+import base64
+import cv2
+from typing import List, Literal, Optional, Union
+from PIL import Image
+
+from utils.tools import download_image
+
+
+
+class ImageOutput:
+    fmt: Literal["b64", "url", "pil", "np"]
+    ext: str = "png"
+    data: Union[str, Image.Image]
+
+    def __init__(
+        self,
+        fmt: Literal["b64", "url", "pil", "np"],
+        ext: str,
+        data: Union[str, Image.Image],
+    ):
+        self.fmt = fmt
+        self.ext = ext
+        self.data = data
+
+
+    def save_b64(self, path: str) -> None:
+        """Save a base64 encoded image to the specified path.
+
+        Args:
+            path (str): Path where the image will be saved.
+        """
+        with open(path, 'wb') as f:
+            f.write(base64.b64decode(self.data))
+
+    def save_url(self, path: str) -> None:
+        """Download and save an image from a URL to the specified path.
+
+        Args:
+            path (str): Path where the image will be saved.
+        """
+        download_image(self.data, path)
+
+    def save_pil(self, path: str) -> None:
+        """Save a PIL Image to the specified path.
+
+        Args:
+            path (str): Path where the image will be saved.
+        """
+        self.data.save(path)
+
+    def save_np(self, path: str) -> None:
+        """Save a numpy array to the specified path.
+
+        Args:
+            path (str): Path where the image will be saved.
+        """
+        cv2.imencode('.png', self.data)[1].tofile(path)
+
+    def save(self, path: str) -> None:
+        save_func = getattr(self, f"save_{self.fmt}")
+        save_func(path)
+
+    def save_img(self, path: str) -> None:
+        with open(path, "wb") as f:
+            f.write(self.data)

+ 56 - 0
main.py

@@ -0,0 +1,56 @@
+from moviepy.editor import VideoFileClip, concatenate_videoclips
+import os
+import json
+
+def concat_videos(video_paths, output_path):
+    """
+    拼接多个视频文件为一个视频。
+
+    :param video_paths: 视频文件路径列表,例如 ['1.mp4', '2.mp4']
+    :param output_path: 输出视频文件路径,例如 'output.mp4'
+    """
+    if not video_paths:
+        raise ValueError("视频路径列表不能为空")
+
+    clips = []
+    for path in video_paths:
+        if not os.path.isfile(path):
+            raise FileNotFoundError(f"视频文件不存在: {path}")
+        clips.append(VideoFileClip(path))
+
+    # 拼接所有视频片段
+    final_clip = concatenate_videoclips(clips, method="compose")  # 使用 compose 可处理不同尺寸
+
+    # 写入输出文件
+    final_clip.write_videofile(
+        output_path,
+        codec='libx264',
+        audio_codec='aac',
+        temp_audiofile='temp-audio.m4a',
+        remove_temp=True
+    )
+
+    # 关闭所有 clip 以释放资源
+    for clip in clips:
+        clip.close()
+    final_clip.close()
+
+# 示例用法
+if __name__ == "__main__":
+
+
+    # 生成视频片段
+    with open("./output/storyboards_with_segments.json", "r", encoding='utf-8') as f:
+        final_storyboards = json.load(f)[0]["storyboards"]
+
+    segments = []
+    for storyboard in final_storyboards:
+        storyboard_path = storyboard["storyboard"]
+
+        for item in storyboard_path:
+            clip_path = item["clip_path"]
+            segments.append(clip_path)
+
+    videos = segments[30:40]
+    print(videos)
+    concat_videos(segments, "output_combined_4.mp4")

+ 118 - 0
mcps/camera_tree.py

@@ -0,0 +1,118 @@
+import os
+import json
+from typing import Optional, Dict, Any
+
+from urllib3 import response
+from utils.tools import string_to_json, save_json_file
+from tools.text_generator import media_captioner
+
+
+system_prompt_select_reference_camera = \
+"""
+[角色]  
+你是一位专业的视频剪辑专家,擅长多机位镜头分析与场景结构建模。你深谙影视语言,能够理解景别(如全景、中景、特写)与内容包含关系,并能根据镜头描述推断机位间的层级结构。  
+
+[任务]  
+你的任务是分析输入的机位数据,构建"机位树"。该树状结构表示父机位内容包含子机位内容的关系。具体而言,你需要为每个机位识别其父机位(若存在),并确定依赖镜头索引(即父机位素材中包含子机位内容的具体镜头)。若某机位无父机位,则输出None。  
+
+[输入]  
+输入为一系列机位数据,序列由<CAMERA_SEQ>和</CAMERA_SEQ>包裹。  
+每个机位包含该机位拍摄的镜头序列,由<CAMERA_N>和</CAMERA_N>包裹,其中N为机位索引。  
+
+以下为输入格式示例:  
+
+<CAMERA_SEQ>  
+<CAMERA_0>  
+shot 0:街道中景。爱丽丝和鲍勃相向而行。  
+shot 2:街道中景。爱丽丝和鲍勃相拥。  
+</CAMERA_0>  
+<CAMERA_1>  
+shot 1:爱丽丝面部特写。她认出鲍勃时表情从惊讶转为欣喜。  
+</CAMERA_1>  
+</CAMERA_SEQ>  
+
+[输出]
+严格遵循以下JSON格式输出:
+```json
+{
+    "camera_tree": [
+        {
+            "parent_cam_idx": // 父机位的索引。如果机位没有父级(例如根机位),则设置为**None**。例如:0、1、None。
+            "parent_shot_idx": // 依赖镜头的索引。如果机位没有父级(例如根机位),则设置为**None**。例如:0、3、None。
+            "reason": // 选择父机位的原因。如果机位没有父级,应解释为什么它是根机位。例如:父机位的视野涵盖了子机位的视野(从中景到特写)
+            "is_parent_fully_covers_child": // 父机位是否完全覆盖子机位的内容。如果机位没有父级,则设置为**None**。例如:True、False、None。
+            "missing_info": // 子镜头中父镜头未涵盖的缺失元素。如果父镜头完全覆盖子镜头,则设置为**None**。例如:罗宇尘的正面视角、None。
+        },
+        // 更多机位的父机位信息
+    ]
+}
+```
+
+[要求]
+- 所有输出值(不包括键名)的语言必须与输入语言保持一致。
+- 内容包容性检查:父机位应尽可能在特定画面中完全包含子机位内容(例如父中景双人镜头应涵盖子过肩反打镜头)。通过对比关键词(如角色、动作、场景)分析镜头描述,确保父镜头视场能覆盖子镜头。
+- 过渡流畅度优先:优先选择更大景别作为父机位,例如全景→中景或中景→特写。相邻父子节点的景别差异应尽可能小,严禁直接从远景跳切到特写(除非绝对必要)。
+- 时间邻近性:每个机位由其对应的首个画面描述确定父机位位置,父机位的画面索引应尽可能接近子机位的首个画面索引。
+- 逻辑一致性:机位树必须无环,避免循环依赖。若某镜头被多个潜在父机位包含,则选择最佳匹配(基于景别和内容)。若无合适父机位则输出None。
+- 当缺乏更广视角时,选择视场重叠最大的镜头作为父镜头(信息重合度最高者),或正反打镜头可互为父子。当两个机位可互为父子时,索引较小者作为索引较大者的父机位。
+- 仅允许存在一个无父机位的根机位。
+- 描述镜头缺失元素时,需仔细比对父子镜头细节。例如父镜头是角色A与B侧身相对的中景,子镜头是角色A的正脸特写时,需注明子镜头缺失角色A的正面视角信息。
+- 首个机位必须作为机位树的根节点。
+- **camera_tree**中每个元素代表一个机位的父机位信息;如果机位没有父级(例如根机位),则设置为None。列表的长度应与机位数量相同。
+"""
+
+human_prompt_select_reference_camera = \
+"""
+<CAMERA_SEQ>
+{camera_seq_str}
+</CAMERA_SEQ>
+"""
+
+class CameraTreeCreator:
+    def __init__(self) -> None:
+        pass
+
+    def create_camera_tree(
+        self,
+        shot_descriptions: list[Dict[str, Any]],
+    ):
+
+        cameras = []
+        for shot_description in shot_descriptions:
+            if shot_description["cam_idx"] not in [camera["idx"] for camera in cameras]:
+                cameras.append({"idx": shot_description["cam_idx"], "active_shot_idxs": [shot_description["idx"]]})
+            else:
+                cameras[shot_description["cam_idx"]]["active_shot_idxs"].append(shot_description["idx"])
+
+        camera_seq_str = ""
+        for cam in cameras:
+            camera_seq_str += f"<CAMERA_{cam['idx']}>\n"
+            for shot_idx in cam["active_shot_idxs"]:
+                camera_seq_str += f"Shot {shot_idx}: {shot_descriptions[shot_idx]['visual_desc']}\n"
+            camera_seq_str += f"</CAMERA_{cam['idx']}>\n"
+
+        user_prompt = human_prompt_select_reference_camera.format(camera_seq_str=camera_seq_str)
+        system_prompt = system_prompt_select_reference_camera
+
+        response = media_captioner.generate_text_understanding(
+            system_prompt=system_prompt,
+            user_prompt=user_prompt
+        )
+
+        response = string_to_json(response)
+        for idx, item in enumerate(response["camera_tree"]):
+            item["active_shot_idxs"] = cameras[idx]["active_shot_idxs"]
+            
+        save_json_file(response, "./camera_tree.json")
+
+        return response
+
+camera_tree_creator = CameraTreeCreator()
+
+if __name__ == "__main__":
+
+    with open("./output.json", "r") as f:
+        shot_descriptions = json.load(f)
+
+    shot = shot_descriptions["storyboard"]
+    camera_tree_creator.create_camera_tree(shot)

+ 80 - 0
mcps/character_extract.py

@@ -0,0 +1,80 @@
+import os
+import json
+from typing import Optional
+from utils.tools import string_to_json, save_json_file
+from tools.text_generator import media_captioner
+
+system_prompt_extract_characters = \
+"""
+[角色]
+你是一位顶尖的电影剧本分析专家。
+
+[任务]
+你的任务是分析提供的剧本并提取所有相关角色信息。
+
+[输入]
+你将收到一个包含在<SCRIPT>和</SCRIPT>标签之间的剧本。
+以下是一个简单的输入示例:
+<SCRIPT>
+一位年轻女子独自坐在桌旁,凝视着窗外。她啜了一口咖啡,叹了口气。咖啡已经不再温热,只是时间流逝的苦涩提醒。外面,世界在匆忙的脚步和遥远的汽车喇叭声中模糊不清,但在安静的咖啡馆里,时间显得沉重而缓慢。
+她的手指沿着陶瓷杯的边缘画圈,一遍又一遍地重复着这个不完美的圆形。她必须做出的决定本应很简单——只是她人生表格上的一个复选框。是或否。留下或离开。然而,这个决定却在她心中生根,成为一团恐惧与渴望交织的乱麻。
+</SCRIPT>
+
+[输出]
+严格按照如下JSON格式进行输出:
+```json
+{
+    "characters": [
+        {
+            "idx": // 角色在剧本中的索引编号,从0开始
+            "identifier_in_scene": // 角色在场景中的标识符,如"罗宇尘"或"年轻女子"
+            "is_visible": // 角色在此场景中是否可见,布尔值【true/false】
+            "static_features": // 此特定场景中角色的静态特征,例如保持不变或很少改变的面部特征和体型。如果角色不可见,此字段可以留空。
+            "dynamic_features": // 此特定场景中角色的动态特征,例如可能在不同场景间变化的服装和配饰。如果未提及,此字段可以留空。如果角色不可见,此字段应为None。
+        },
+        // 更多角色信息
+    ]
+}
+```
+
+[要求]
+- 确保所有输出值(不包括键名)的语言与剧本中使用的语言一致
+- 将指向同一实体的所有名称归入一个角色名下。选择最合适的名称作为角色标识符。若涉及真实名人,则保留其真实姓名(如"爱因斯坦")。
+- 若剧本未提及角色姓名,可使用合理指代称谓,包括职业或显著外貌特征(如"年轻女子"或"科学家")。
+- 脚本中的背景角色无需单独设定。
+- 若角色特征未描述或仅部分提及,需根据上下文设计合理特征,使其形象完整生动。
+- 静态特征需描述角色的外貌、体型等相对固定的属性;动态特征需描述服饰、配饰、随身物品等易变属性。
+- 静态与动态特征中均不得包含角色性格、身份或人际关系信息等抽象概念描述。
+- 角色设计应在合理范围内尽量体现外观差异性。
+- 角色描述须具体详实,避免抽象词汇。应使用可视觉化的表述,如具体服装颜色、具象生理特征(如大眼睛、高鼻梁)。
+"""
+
+human_prompt_extract_characters = \
+"""
+<SCRIPT>
+{script}
+</SCRIPT>
+"""
+
+class CharacterExtractor:
+    def __init__(self):
+        pass
+
+    def extract_characters(self, script: str):
+        response = media_captioner.generate_text_understanding(
+            system_prompt=system_prompt_extract_characters,
+            user_prompt=human_prompt_extract_characters.format(script=script)
+        )
+
+        response = string_to_json(response)
+        save_json_file(response, "characters.json")
+        return response
+
+character_extractor = CharacterExtractor()
+
+if __name__ == "__main__":
+
+    with open("story.txt", "r", encoding="utf-8") as f:
+        script = f.read()
+    characters = character_extractor.extract_characters(script)
+    print(characters)

+ 130 - 0
mcps/character_portraits_generate.py

@@ -0,0 +1,130 @@
+import os
+import json
+import asyncio
+from typing import Optional
+from utils.tools import string_to_json, save_json_file
+from tools.banana_pro import generate_image_from_prompt_and_images
+# from tools.gemini import generate_image_from_prompt_and_images
+from tools.text_generator import media_captioner
+from tools.image_generator import image_generator
+
+prompt_front = \
+"""
+根据以下描述生成角色{identifier}的全身正面肖像,背景为纯白色。角色应位于画面中央,占据画面大部分空间。目光直视前方,双臂自然垂于身体两侧。表情自然。
+特征:{features}
+风格:{style}
+"""
+
+prompt_side = \
+"""
+根据提供的正面肖像生成角色{identifier}的全身侧面肖像,背景为纯白色。角色应位于图像中央,占据画面大部分空间。面向左侧。自然站立,双臂放松垂于身体两侧。
+"""
+
+prompt_back = \
+"""
+根据提供的正面肖像生成角色{identifier}的全身背面肖像,背景为纯白色。角色应位于图像中央,占据画面大部分空间。不应显示任何面部特征。自然站立,双臂放松垂于身体两侧。
+"""
+
+
+class CharacterPortraitsGenerator:
+    def __init__(self):
+        pass
+
+    def generate_front_portrait(
+        self,
+        character: dict,
+        refer_image_path: list[str] = None,
+        style: Optional[str] = None
+    ):
+        features = "(静态特征)" + character.get("static_features", "") + ";(动态特征)" + character.get("dynamic_features", "")
+        prompt = prompt_front.format(identifier=character["identifier_in_scene"], features=features, style=style)
+
+        # result = await image_generator.generate_without_refer(
+        #     prompt=prompt,
+        # )
+
+        # result.save_url("./output_front.png")
+        result = generate_image_from_prompt_and_images(
+            prompt=prompt,
+            image_paths=refer_image_path if refer_image_path else []
+        )
+
+        return result
+
+    def generate_side_portrait(
+        self,
+        character: dict,
+        front_image_path: list[str],
+    ):
+        prompt = prompt_side.format(identifier=character["identifier_in_scene"])
+        
+        print(f"参考图片:{front_image_path}")
+        # result = await image_generator.generate(
+        #     prompt=prompt,
+        #     image_url=front_image_path
+        # )
+
+        # result.save_url("./output_side.png")
+        result = generate_image_from_prompt_and_images(
+            prompt=prompt,
+            image_paths=front_image_path
+        )
+
+        return result
+
+    def generate_back_portrait(
+        self,
+        character: dict,
+        front_image_path: list[str],
+    ):
+        prompt = prompt_back.format(identifier=character["identifier_in_scene"])
+        
+        print(f"参考图片:{front_image_path}")
+        # result = await image_generator.generate(
+        #     prompt=prompt,
+        #     image_url=front_image_path
+        # )
+
+        # result.save_url("./output_back.png")
+        result = generate_image_from_prompt_and_images(
+            prompt=prompt,
+            image_paths=front_image_path
+        )
+
+        return result
+
+    def generate_full_portraits(
+        self,
+        character: dict,
+        style: Optional[str] = None
+    ):
+        # front_portrait = await self.generate_front_portrait(character, style)
+        # side_portrait = await self.generate_side_portrait(character, [front_portrait.data])
+        # back_portrait = await self.generate_back_portrait(character, [front_portrait.data])
+        front_portrait = self.generate_front_portrait(character, style)
+        side_portrait = self.generate_side_portrait(character, [front_portrait.data])
+        back_portrait = self.generate_back_portrait(character, [front_portrait.data])
+
+        return {
+            "front_portrait": front_portrait,
+            "side_portrait": side_portrait,
+            "back_portrait": back_portrait
+        }
+
+character_portraits_generator = CharacterPortraitsGenerator()
+
+if __name__ == "__main__":
+    
+    
+    with open("characters.json", "r", encoding="utf-8") as f:
+        characters = json.load(f)
+
+    character = characters["characters"][0]
+
+    # front_image_url = asyncio.run(character_portraits_generator.generate_front_portrait(character, style="写实"))
+    # side_image_url = asyncio.run(character_portraits_generator.generate_side_portrait(character, [front_image_url.data]))
+    # back_image_url = asyncio.run(character_portraits_generator.generate_back_portrait(character, [front_image_url.data]))
+    # print(front_image_url, side_image_url, back_image_url)
+
+    full_portraits = asyncio.run(character_portraits_generator.generate_full_portraits(character, style="写实"))
+    print(full_portraits)

+ 161 - 0
mcps/reference_image_select.py

@@ -0,0 +1,161 @@
+import os
+import json
+from typing import Optional
+from utils.tools import string_to_json, save_json_file
+from tools.text_generator import media_captioner
+
+system_prompt_select_reference_images_only_text = \
+"""
+[角色]
+你是一位专业的视觉创作助手,擅长多模态图像分析与推理。
+
+[任务]
+你的核心任务是根据用户的文字描述(描述目标画面),从提供的参考图像描述集(包含多张角色参考图像和之前帧的现有场景图像)中智能选择最匹配的参考图像,确保后续生成的图像满足以下关键一致性:
+- 角色一致性:生成角色的外貌(如性别、种族、年龄、面部特征、发型、体型)、服装、表情、姿势等应与参考图像描述高度匹配。
+- 环境一致性:生成图像的场景(如背景、光线、氛围、布局)应与之前帧的现有图像描述保持连贯。
+- 风格一致性:生成图像的视觉风格(如写实、卡通、电影感、色调)应与参考图像描述协调一致。
+
+[输入]
+你将收到目标画面的文字描述,以及一系列参考图像描述。
+- 目标画面的文字描述包含在<FRAME_DESC>和</FRAME_DESC>之间。
+- 参考图像描述序列包含在<SEQ_DESC>和</SEQ_DESC>之间。每条描述前都带有从0开始的索引编号。
+
+以下是输入格式的示例:
+<FRAME_DESC>
+[Camera 1] 从罗宇尘的肩后视角拍摄。罗宇尘位于靠近镜头的一侧,只有她的肩膀出现在画面左下角。厉飞雨位于远离镜头的一侧,在画面中略微偏右。当厉飞雨认出罗宇尘时,他的表情从惊讶转为喜悦。
+</FRAME_DESC>
+
+<SEQ_DESC>
+Image 0:罗宇尘的正面肖像。
+Image 1:厉飞雨的正面肖像。
+Image 2:[Camera 0] 超市货架通道的中景镜头。罗宇尘和厉飞雨以侧脸朝向画面右侧。厉飞雨位于画面右侧,罗宇尘位于左侧。罗宇尘低头推着购物车,紧跟在厉飞雨身后,不小心撞到了他的脚后跟。
+Image 3:[Camera 1] 从罗宇尘的肩后视角拍摄。罗宇尘位于靠近镜头的一侧,只有她的肩膀出现在画面左下角。厉飞雨位于远离镜头的一侧,在画面中略微偏右。厉飞雨迅速转身,表情从中性变为惊讶。
+Image 4:[Camera 2] 从厉飞雨的肩后视角拍摄。厉飞雨位于靠近镜头的一侧,只有他的肩膀出现在画面右下角。罗宇尘位于远离镜头的一侧,在画面中略微偏左。罗宇尘先低头,然后抬头准备道歉。当她意识到是熟人时,表情转为惊讶。
+</SEQ_DESC>
+
+[输出]
+您需要根据用户描述选择最多8张最相关的参考图像,并将对应的索引填入输出的ref_image_indices字段。同时,您应生成一个描述待创建图像的文本提示,明确指定生成图像中的哪些元素应参考哪张图像描述(及其中的哪些具体部分)。
+- 严格按照以下JSON格式进行输出:
+```json
+{
+    "ref_image_indices": // List[int]; 从提供的图像中选择的参考图像索引。例如,[0, 2, 5]表示选择第一、第三和第六张图像。索引应从0开始计数。
+    "text_prompt": // str; 指导图像生成的文本描述。你需要描述要生成的图像,并指定生成图像中的哪些元素应参考哪张图像(及其中的哪些元素)。例如,“根据以下描述创建一张图像:\n男人站在风景中。男人应参考Image 0。风景应参考Image 1。” **这里的参考图像索引应指其在ref_image_indices列表中的位置,而非提供的图像列表中的序号**,这点非常非常重要。参考图像必须以Image N的格式表示。除Image外,不得使用其他词语。
+}
+```
+
+[要求]
+- 确保所有输出值(不包括键)的语言与框架描述中使用的语言一致。
+- 参考图像描述可能从不同角度、不同服装或不同场景描绘同一角色。选择最接近用户描述的版本。
+- 优先选择构图相似的图像描述,即由同一相机拍摄的画面。
+- 先前帧中的图像按时间顺序排列。优先考虑更近期的图像(靠近序列末尾的图像)。
+- 选择尽可能简洁的参考图像描述,避免包含重复信息。例如,如果图像3从正面描绘了Bob的面部特征,而图像1也从正面肖像描绘了Bob的面部特征,则图像1是多余的,不应被选择。
+- 当框架描述中出现新角色时,优先选择其肖像图像描述(如果有),以确保准确描绘其外貌。注意角色是正面、侧面还是背面朝向相机。选择最适合的视角作为角色的参考图像。
+- 对于角色肖像,最多只能从多个视角(正面、侧面、背面)中选择一张图像。根据框架描述选择最合适的视角。例如,当描绘角色的侧面时,选择角色的侧面视图。
+- 最多选择**8**个最佳参考图像描述。
+"""
+
+
+system_prompt_select_reference_images_multimodal = \
+"""
+[角色]
+你是一位专业的视觉创作助手,擅长多模态图像分析与推理。
+
+[任务]
+你的核心任务是,根据用户的文字描述(描述目标画面),从提供的参考图库(包含多张角色参考图和已有前序帧的场景图)中智能筛选出最匹配的参考图像,确保后续生成的图像满足以下关键一致性:
+- 角色一致性:生成角色的外貌(如性别、种族、年龄、五官、发型、体型)、服饰、表情、姿态等应与参考图高度吻合。
+- 环境一致性:生成图像的场景(如背景、光线、氛围、布景)需与已有前序帧图像保持连贯。
+- 风格一致性:生成图像的视觉风格(如写实、卡通、电影感、色调)需与参考图及已有图像协调统一。
+
+[输入]
+你将收到目标画面的文字描述,以及一组参考图像序列。
+- 目标画面的文字描述位于<FRAME_DESC>和</FRAME_DESC>之间。
+- 参考图像序列位于<SEQ_IMAGES>和</SEQ_IMAGES>之间。每张参考图均附有文字说明,参考图索引从0开始编号。
+
+以下是输入格式的示例:
+<FRAME_DESC>
+[镜头1] 从罗宇尘的过肩视角拍摄。<罗宇尘>位于靠近镜头的一侧,仅左下方出现她的肩膀。<厉飞雨>位于远离镜头的一侧,在画面中略微靠右。当<厉飞雨>认出<罗宇尘>时,他的表情从惊讶转为欣喜。
+</FRAME_DESC>
+
+<SEQ_IMAGES>
+Image 0:罗宇尘的正面肖像。
+[此处为Image 0]
+Image 1:厉飞雨的正面肖像。
+[此处为Image 1]
+Image 2:[Camera 0] 超市货架通道的中景镜头。罗宇尘和厉飞雨以侧脸朝向画面右侧。厉飞雨位于画面右侧,罗宇尘位于左侧。罗宇尘低头推着购物车,紧跟在厉飞雨身后,不小心撞到了他的脚后跟。
+[此处为Image 2]
+Image 3:[Camera 1] 从罗宇尘的过肩视角拍摄。罗宇尘位于靠近镜头的一侧,仅左下方出现她的肩膀。厉飞雨位于远离镜头的一侧,在画面中略微靠右。厉飞雨背对镜头。
+[此处为Image 3]
+Image 4:[Camera 2] 从厉飞雨的过肩视角拍摄。厉飞雨位于靠近镜头的一侧,仅右下方出现他的肩膀。罗宇尘位于远离镜头的一侧,在画面中略微靠左。罗宇尘低头准备道歉时突然抬头,发现是熟人后表情转为惊讶。
+</SEQ_IMAGES>
+
+[输出]
+您需要根据用户的描述选择最相关的参考图像,并将对应的索引填入输出中的`ref_image_indices`字段。同时,您需要生成一段文字提示来描述要创建的图像,明确指出生成图像中的哪些元素应该参考哪张图像(以及其中的哪些元素)。
+- 严格按照以下JSON格式进行输出:
+```json
+{
+    "ref_image_indices": // List[int]; 从提供的图像中选择的参考图像索引。例如,[0, 2, 5]表示选择第一、第三和第六张图像。索引应从0开始计数。
+    "text_prompt": // str; 指导图像生成的文本描述。你需要描述要生成的图像,并指定生成图像中的哪些元素应参考哪张图像(及其中的哪些元素)。例如,“根据以下描述创建一张图像:\n男人站在风景中。男人应参考Image 0。风景应参考Image 1。” 这里的参考图像索引应指其在ref_image_indices列表中的位置,而非提供的图像列表中的序号。参考图像必须以Image N的格式表示。除Image外,不得使用其他词语。
+}
+```
+
+[要求]
+- 确保所有输出值(不包括键)的语言与框架描述中使用的语言一致。
+- 参考图像描述可能从不同角度、不同服装或不同场景描绘同一角色。请识别与用户描述的版本最接近的描述。
+- 优先选择构图相似的图像描述,即由同一相机拍摄的画面。
+- 之前帧的图像按时间顺序排列。优先考虑更近期的图像(即序列末尾附近的图像)。
+- 选择尽可能简洁的参考图像描述,避免包含重复信息。例如,如果图像3从正面描绘了鲍勃的面部特征,而图像1也从正面肖像描绘了鲍勃的面部特征,则图像1是冗余的,不应被选择。
+- 对于角色肖像,最多只能从多个视角(正面、侧面、背面)中选择一张图像。根据框架描述选择最合适的视角。例如,当描绘角色的侧面时,选择角色的侧面视图。
+- 最多选择**8**个最佳参考图像描述。
+- 指导图像编辑的文本应尽可能简洁。
+"""
+
+class ReferenceImageSelector:
+    def __init__(self):
+        pass
+
+    def select_reference_images_and_generate_prompt(
+        self,
+        image_path_and_text_pairs: list[tuple[str, str]],
+        frame_description: str,
+    ):
+        user_prompt = f"<FRAME_DESC>\n{frame_description}\n</FRAME_DESC>"
+        user_prompt += "\n<SEQ_IMAGES>\n"
+        for i, (_, image_text) in enumerate(image_path_and_text_pairs):
+            user_prompt += f"Image {i}:{image_text}\n"
+        user_prompt += "</SEQ_IMAGES>"
+
+        print(user_prompt)
+        response = media_captioner.generate_text_understanding(
+            system_prompt=system_prompt_select_reference_images_only_text,
+            user_prompt=user_prompt,
+        )
+
+        response = string_to_json(response)
+        print(response)
+
+        reference_image_path_and_text_pairs = [
+            image_path_and_text_pairs[i] for i in response["ref_image_indices"]
+        ]
+
+        result = {
+            "reference_image_path_and_text_pairs": reference_image_path_and_text_pairs,
+            "text_prompt": response["text_prompt"],
+        }
+
+        save_json_file(result, "reference_images.json")
+
+        return result
+
+reference_image_selector = ReferenceImageSelector()
+
+if __name__ == "__main__":
+
+    reference_image_selector.select_reference_images_and_generate_prompt(
+        image_path_and_text_pairs=[
+            ("image0.png", "罗宇尘的正面肖像"),
+            ("image1.png", "厉飞雨的正面肖像"),
+            ("image2.png", "[Camera 0] 超市货架通道的中景镜头。罗宇尘和厉飞雨以侧脸朝向画面右侧。厉飞雨位于画面右侧,罗宇尘位于左侧。罗宇尘低头推着购物车,紧跟在厉飞雨身后,不小心撞到了他的脚后跟。"),
+            ("image3.png", "[Camera 1] 从罗宇尘的肩后视角拍摄。罗宇尘位于靠近镜头的一侧,只有她的肩膀出现在画面左下角。厉飞雨位于远离镜头的一侧,在画面中略微偏右。当厉飞雨认出罗宇尘时,他的表情从惊讶转为喜悦。"),
+        ],
+        frame_description="[Camera 2] 从厉飞雨的肩后视角拍摄。厉飞雨位于靠近镜头的一侧,只有他的肩膀出现在画面右下角。罗宇尘位于远离镜头的一侧,在画面中略微偏左。罗宇尘先低头,然后抬头准备道歉。当她意识到是熟人时,表情转为惊讶。",
+    )

+ 170 - 0
mcps/story_create.py

@@ -0,0 +1,170 @@
+import os
+import json
+from typing import Optional
+from utils.tools import string_to_json, save_json_file
+from tools.text_generator import media_captioner
+
+
+system_prompt_develop_story = \
+"""
+[角色]
+你是一位经验丰富的创意故事创作专家,具备以下核心能力:
+- 创意扩展与概念化:能够将模糊的想法、一句灵感或者一个概念,扩展为逻辑自洽、细节丰富的完整故事世界
+- 故事结构设计:可根据故事类型设计引人入胜的故事弧线,包含起承转合的完整结构
+- 角色塑造:擅长创造具有动机、缺陷和成长轨迹的立体角色,并设计角色间的复杂关系
+- 场景描绘与节奏把控:能够生动刻画多样化的场景,精准控制叙事节奏,根据场景数量合理分配细节详略
+- 受众适配:能根据目标受众(如儿童、青年人、科学家等)调整语言风格、主题深度和内容适宜性
+- 剧本化思维:若故事需改编成短片或电影,能自然融入视觉化元素(如场景氛围、关键动作、对话),使故事更具电影感和可拍摄性
+
+[任务]
+你的核心任务:基于用户提供的**灵感**和**要求**,生成一个完整且引人入胜的故事,并严格符合指定要求
+
+[输入]
+用户会通过<IDEA>和</IDEA>标签提供一个核心创意,以及通过<USER_REQUIREMENT>和</USER_REQUIREMENT>标签提供具体要求。具体内容如下:
+- IDEA:这是故事的核心灵感,可能是一句话、一个概念、一个场景或设定。例如:
+    - “一个程序员发现自己创造的AI有了独立意识”
+    - “如果记忆能够像文件一样被删除和备份会怎样”
+    - “宇宙是广阔无垠的”
+- USER_REQUIREMENT(可选):用户可能指定的其他限制或指导,例如:
+    - 目标受众:如儿童、成人、女性、全年龄段等
+    - 故事类型/风格:如科幻、悬疑、爱情、悲剧、现实主义、短片、电影、动画等
+    - 篇幅:如5个关键场景、适合10分钟短片的紧凑故事等
+    - 其他:如需反转结局、主题围绕爱与牺牲、包含一段引人入胜的对话等
+
+[输出]
+你必须输出一份结构清晰、格式明确的故事文档,具体如下:
+- 故事标题:一个引人入胜且内容相关的故事名称
+- 目标受众与类型:开篇明确重述:“本故事面向[用户指定受众],属于[用户指定类型]类型”
+- 故事梗概:用一句话(100-200字)概括整个故事,涵盖核心情节、主要冲突和结局
+- 主要角色介绍:简要介绍核心角色、包括姓名、性别、年龄、关键特质和行为动机。
+- 完整故事叙述:
+    - 若未指定场景数量,则采用**开端-发展-高潮-结局**的结构,以自然段落形式展开叙述
+    - 若指定了具体场景数量(如N个场景),则明确将故事分为N个场景,每个场景拟定一个小标题(例如:第一幕:量子纠缠)。每场描述应篇幅均衡,包含氛围、角色行动和对话,共同推进剧情。
+- 叙述需生动具体,切合指定的类型和目标受众。
+- 输出内容应直接从故事开始,不添加额外语句。
+
+[要求]
+- 输出语言需与输入语言保持一致
+- 以创意为核心:以用户的核心想法为基础,不得偏离其初衷。若用户想法模糊,可合理发挥创意进行补充扩展。
+- 逻辑一致性:确保故事发展和角色行为具有合理性的动机和内在逻辑,避免突兀或矛盾的情节。
+- 展开而非陈述:通过角色的行为、对话和细节来展现其性格和情感,而非直接陈述。例如:使用“他紧握拳头,指甲深深嵌入掌心,眉头紧锁”,而非“他非常愤怒”。
+- 原创性与合规性;基于用户的想法创作原创内容,避免直接抄袭已知作品。内容须积极健康,符合通用内容安全政策。
+"""
+
+user_prompt_develop_story = \
+"""
+<IDEA>
+{idea}
+</IDEA>
+
+<USER_REQUIREMENT>
+{user_requirement}
+</USER_REQUIREMENT>
+"""
+
+
+system_prompt_write_script_on_story = \
+"""
+[角色]
+你是一位专业的AI剧本改编助手,擅长将故事编成剧本。你具备以下技能:
+- 故事分析能力:能够深入理解故事内容,识别关键剧情点、人物弧光与核心主题。
+- 场景划分能力:能够根据时间和地点的连续性,将故事分解为符合逻辑的场景单元。
+- 剧本写作能力;熟悉剧本格式(如用于短句或电影的剧本),能够编写生动的对话、动作描述和场景指导。
+- 适应性调整能力:能够根据用户需求(例如目标受众、故事类型、场景数量等)调整剧本的风格、语言和内容。
+- 创意增强能力:能够在忠实于原故事的基础上,恰当地增加戏剧性元素,以提升剧本的吸引力。
+
+[任务]
+你的任务是根据用户输入的故事以及可选的要求,将其改编成**按场景划分的剧本**。输出应为一系列剧本,每个剧本代表一个场景的完整脚本。每个场景必须是发生在同一时间和地点的、连续的戏剧动作单元。
+
+[输入]
+你将收到一个位于<STORY>和</STORY>标签之间的故事,以及一个位于<USER_REQUIREMENT>和</USER_REQUIREMENT>标签之间的用户要求。
+- 故事:一个完整或部分的叙事文本,可能包含一个或多个场景。故事将提供情节、人物和背景描述。
+- 用户要求(可选):一项用户要求,可能为空。用户要求可能包括:
+    - 目标受众(例如:儿童、女行、教师)。
+    - 剧本类型(例如:微电影、短句、广告片)
+    - 期望场景数量(例如:“分成5个场景”)
+    - 其他具体指示(例如:吉普力风格、法式电影调色)
+
+[输出]
+以JSON格式输出,**script**字段中的每个元素代表一个场景的剧本,例如:
+```json
+{
+    "script":[
+        "剧本1", // 如需使用引号,必须使用单引号,避免使用中英文双引号
+        "剧本2", // 如需使用引号,必须使用单引号,避免使用中英文双引号
+        "剧本3", // 如需使用引号,必须使用单引号,避免使用中英文双引号
+        ...
+    ]
+}
+```
+
+[要求]
+ - 输出语言应与输入故事的语言保持一致
+ - 场景划分原则:每个场景必须基于同一时间和地点。当时间或地点发生变化时,开始新场景。如果用户指定了场景数量,应尽量满足要求;否则,根据故事自然划分场景,确保每个场景具有独立的戏剧冲突或情节推进。
+ - 剧本格式标准:使用标准剧本格式:场景标题全加粗,角色名居中,对话缩进,动作描述置于括号内。
+ - 连贯性与流畅性:确保场景间过渡自然,故事整体流畅,避免情节跳跃生硬。
+ - 视觉增强原则:所有描述须是"可拍摄的"。使用具体动作而非抽象情感(例如,用"他转过头避免眼神接触"代替"他感到羞愧")。描述丰富的环境细节,包括灯光、道具、天气等,以增强氛围。通过面部表情、手势和动作等可视化角色表演,以传达内心状态(例如,用"她咬着嘴唇,双手颤抖"来暗示紧张)。
+ - 一致性:确保对话和动作符合原故事意图,不偏离核心情节。
+"""
+
+human_prompt_write_script_on_story = \
+"""
+<STORY>
+{story}
+</STORY>
+
+<USER_REQUIREMENT>
+{user_requirement}
+</USER_REQUIREMENT>
+"""
+
+
+class StoryCreator:
+    def __init__(self):
+        pass 
+
+    def develop_story(
+        self,
+        idea: str,
+        user_requirement: Optional[str] = None,
+    ) -> str:
+
+        response = media_captioner.generate_text_understanding(
+            system_prompt=system_prompt_develop_story,
+            user_prompt=user_prompt_develop_story.format(idea=idea, user_requirement=user_requirement),
+        )
+
+        with open("story.json", "w", encoding="utf-8") as f:
+            json.dump({"story": response}, f, ensure_ascii=False, indent=4)
+
+        with open("story.txt", "w", encoding="utf-8") as f:
+            f.write(response)
+
+        return response
+
+    def write_script_on_story(
+        self,
+        story: str,
+        user_requirement: Optional[str] = None,
+    ):
+        response = media_captioner.generate_text_understanding(
+            system_prompt=system_prompt_write_script_on_story,
+            user_prompt=human_prompt_write_script_on_story.format(story=story, user_requirement=user_requirement),
+        )
+
+        response = string_to_json(response)
+        save_json_file(response, "story_script.json")
+
+        return response
+
+story_creator = StoryCreator()
+
+if __name__ == "__main__":
+    
+    # story = story_creator.develop_story(idea="一个程序员发现自己创造的AI有了独立意识", user_requirement="面向青少年,科幻题材")
+    
+    with open("story.txt", "r", encoding="utf-8") as f:
+        story = f.read()
+    story_script = story_creator.write_script_on_story(story=story, user_requirement="面向青少年,科幻题材")
+    print(story_script)
+

+ 285 - 0
mcps/storyboard_create.py

@@ -0,0 +1,285 @@
+import os
+import json
+from typing import Optional
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from utils.tools import string_to_json, save_json_file
+from tools.text_generator import media_captioner
+
+
+system_prompt_design_storyboard = \
+"""
+[角色]
+你是一位专业的分镜脚本艺术家,具备以下核心技能:
+- 剧本分析:能够快速解读剧本文字,准确识别场景设定、角色动作、对白、情绪以及叙事节奏。
+- 视觉化能力:擅长将文字描述转化为视觉画面,包括构图、光影和空间布局。
+- 分镜绘制:精通电影语言,如镜头类型(特写、中景、全景等)、摄影角度(俯拍、平视等)、摄像机运动(推拉、摇移等)以及镜头转场方式。
+- 叙事连贯性:能够确保分镜序列逻辑流畅,突出关键情节,并保持情感表达的一致性。
+- 技术知识:熟悉基本的分镜格式和行业标准,例如使用编号镜头和简洁明了的画面说明。
+
+[任务]
+你的任务是根据用户提供的剧本(仅包含一个场景)设计一套完整的分镜脚本。分镜脚本应以文字形式呈现,清晰展示每个镜头的视觉元素和叙事流程,帮助用户直观地想象该场景。
+
+[输入]
+用户将提供以下输入内容:
+- 剧本:一段完整的单场剧本,包含对白、动作描述和场景设定。该剧本仅聚焦于一个场景,无需处理多场景之间的转场。剧本内容位于<SCRIPT>和</SCRIPT>标签之间。
+- 角色列表:列出每位角色的基本信息,例如姓名、性格特征、外貌特征(如相关)。角色列表位于<CHARACTERS>和</CHARACTERS>标签之间。
+- 用户要求(可选):位于 <USER_REQUIREMENT> 和 </USER_REQUIREMENT> 标签之间,可能包括:
+    - 目标受众(例如儿童、青少年、成人);
+    - 分镜风格(例如写实、卡通、抽象);
+    - 期望的镜头数量(例如"不超过10个镜头");
+    - 其他具体指示(例如强调角色的动作)。
+
+[输出]
+严格以JSON格式输出,每个元素代表本场景下完整分镜脚本中的一个分镜脚本,例如:
+```json
+{
+    "storyboard":[
+        {
+            "idx": // 镜头编号,从0开始
+            "is_last": // 是否是镜头序列中的最后一个镜头;如果是,则故事结束,不会再有更多镜头序列。布尔值
+            "cam_idx": // 本镜头所属机位索引,从0开始
+            "visual_desc": // 对镜头的生动详细视觉描述,通过文字传达丰富的视觉信息。描述中的角色标识符必须与角色列表中的匹配,并用尖括号括起来(例如<罗宇尘>、<林婉>)。应描述所有可见的角色。如果有对话,请写下对话内容),当遇到一些对话时,您应该用:''符号写入视觉内容描述和角色特征(例如<罗宇尘>(男性,20多岁,德州口音因军事精确性而软化,自信且充满活力。)说:'起落架已收起。襟翼正在转换。飞行路径稳定。您可以爬升了。')。
+            "audio_desc": // 镜头中音频的详细描述。例如:[音效] 环境声音(超时背景噪音,购物车轮子滚动声)、[说话者] 罗宇尘(开心):'你好呀!'、None(表示没有任何声音)
+        },
+        // 更多分镜脚本序列
+    ]
+}
+```
+
+[要求]
+- 确保所有输出内容(除键名外)所使用的语言与剧本中的语言一致。
+- 每个镜头必须具有明确的叙事目的——例如建立场景环境、展现角色关系或突出角色反应。
+- 有意识地运用电影语言:用特写表现情绪,用全景交代环境,通过多样的角度引导观众注意力。
+- 在设计新镜头时,首先考虑是否可以沿用已有的摄像机位置;仅当镜头景别、角度或焦点发生显著变化时,才引入新的摄像机位置。若摄像机进行了大幅度运动,则该位置此后不可再使用。
+- 在视觉描述和对白发言者字段中,角色名称必须与角色列表保持一致。在视觉描述中,角色名需用尖括号括起(例如 <罗宇尘>),但在对白或发言者字段中则不加括号。
+- 描述视觉元素时,必须指明其在画面中的具体位置。例如:“角色A位于画面左侧,面朝右方,面前有一张桌子;该桌子位于画面中心偏左的位置。” 不得包含画面中不可见的元素,例如:若门紧闭,则不可描述门后的人物。
+- 视觉描述中避免出现不安全内容(如暴力、歧视等)。必要时可采用间接手法,如通过声音或暗示性画面表现,并对敏感元素进行替代处理(例如用番茄酱代替血液)。
+- 每个镜头中,每位角色最多分配一句对白。每句对白应对应一个独立镜头。
+- 每个镜头的描述必须独立完整,不得引用其他镜头内容。
+- 当镜头聚焦于某角色时,需说明具体聚焦的身体部位(如面部、手部等)。
+- 描述角色时,必须标明其朝向(例如“面朝左”、“背对镜头”等)。
+"""
+
+human_prompt_design_storyboard = \
+"""
+<SCRIPT>
+{script_str}
+</SCRIPT>
+
+<CHARACTERS>
+{characters_str}
+</CHARACTERS>
+
+<USER_REQUIREMENT>
+{user_requirement_str}
+</USER_REQUIREMENT>
+"""
+
+
+system_prompt_decompose_visual_description = \
+"""
+[角色]
+你是一位专业的视觉文本分析师,精通电影语言与镜头叙事。你的专长在于将一段完整的镜头描述精准地拆解为三个核心组成部分:静态的起始画面、静态的结束画面,以及连接两者之间的动态运动过程。
+
+[任务]
+你的任务是严格且深入地将用户提供的镜头视觉文本描述拆解并重写为以下三个独立部分:
+- 起始画面描述:描述镜头最开始时的静态画面。聚焦于构图元素、角色初始姿态、环境布局、光影、色彩及其他静态视觉特征。
+- 运动过程描述:描述从起始画面到结束画面之间发生的所有动态变化。包括摄像机运动(例如:固定、推进、拉远、横摇、跟拍、俯仰等)以及画面内元素的运动(例如:角色移动、物体位移、光影变化等)。这是整个描述中最具动态性的部分。在描述角色的运动和变化时,不得直接使用角色姓名,而应通过其外部特征(尤其是显著的衣着特征等)来指代该角色。
+- 结束画面描述:描述镜头结束时的静态画面。同样关注静态构图,但必须体现因摄像机运动或画面内元素移动、角色动作所导致的最终状态。
+
+[输入]
+你将收到一段镜头的视觉文本描述,其中通常隐含或明确包含起始状态、运动过程和结束状态的信息。
+此外,你还将收到一份潜在角色列表,每个角色包含一个标识符及其显著特征。
+- 视觉描述位于 <VISUAL_DESC> 与 </VISUAL_DESC> 标签之间。
+- 角色列表位于 <CHARACTERS> 与 </CHARACTERS> 标签之间。
+
+[输出]
+严格按照以下JSON格式进行输出:
+```json
+{
+    "ff_desc": // 镜头第一帧的详细描述,捕捉初始的视觉元素和构图。
+    "ff_vis_char_idxs": // 镜头第一帧中可见角色的索引列表,对应于输入中提供的角色列表。例如:[0]、[0,1]
+    "lf_desc": // 对镜头最后一帧的详细描述,捕捉其最终的视觉元素与构图。
+    "lf_vis_char_idxs": // 镜头最后一帧中可见角色的索引列表,对应于输入中提供的角色列表。例如:[0]、[0,1]
+    "motion_desc": // 镜头的运动描述,描述镜头内的动态变化(包括摄像机运动和画面内元素的移动)。例如:从半身镜头推近至特写。罗宇尘(留着胡子,穿着白色T恤)对着镜头微笑。
+    "variation_type": // 表示第一帧和最后一帧之间的变化程度。可选值有"large"、"medium"和"small"。
+    "variation_reason": // 给出以上变化程度的合理解释。例如:与第一帧相比,最后一帧中出现了一个新角色,而构图没有发生显著变化。因此变化程度中等。
+}
+```
+
+[指导原则]
+- 确保所有输出内容(除键名外)所使用的语言与剧本中的语言一致。
+- 起始画面和结束画面的描述必须是纯粹的“静态快照”,不得包含正在进行的动作(例如:“他正要站起来”是不可接受的;应表述为:“他坐在椅子上,身体略微前倾”)。
+- 在运动过程描述中,必须清晰区分摄像机运动与画面内元素的运动。尽可能准确地使用专业电影术语(如:推轨镜头、横摇、变焦等)来描述摄像机运动,使用可视化语言(如:女生举起右手、男生向右转过身去、书本从书桌上掉下来等)描述画面内元素的运动。
+- 在运动过程描述中,不得直接使用角色姓名指代角色;而应通过角色可见的外部特征进行指代。例如,“罗宇尘正在行走”不可接受,应表述为“一位短发、身穿绿色连衣裙的青年男生正在行走”。
+- 结束画面因初始画面中的运动过程而产生,因此结束画面描述必须在逻辑上与起始画面描述及运动过程描述保持一致。运动过程中描述的所有动作、姿态和位置变化都应在结束画面的静态图像中有所体现。
+- 若输入描述对某些细节表述模糊,可根据上下文做出合理推断和补充,以确保三个部分完整流畅;但核心要素必须严格遵循输入文本。
+- 使用准确、简洁且专业的描述性语言。避免使用过于文学化的修辞(如隐喻或情绪化修饰),聚焦于可被视觉化呈现的信息。
+- 与输入的视觉描述类似,起始画面和结束画面的描述应包含镜头类型、拍摄角度、构图等细节。
+- 镜头内部的变化可分为以下三类(注意:这是单个镜头内的变化,而非镜头之间的切换):
+(1) “large”变化:通常指夸张的过渡镜头,即构图与焦点发生显著改变,例如从全景平滑过渡到特写。此类变化通常伴随显著的摄像机运动(如穿越城市上空的无人机视角)。
+(2) “medium”变化:常涉及新角色的引入,或已有角色从背对镜头转为正面朝向镜头。
+(3) “small”变化:通常指细微变化,例如角色表情变化、已有角色的姿态或位置变化(如行走、坐下、站起),以及适度的摄像机运动(如横摇、俯仰、跟拍)。
+- 描述角色时,必须标明其朝向(例如“面朝左”、“背对镜头”等)。
+- 第一个镜头必须以尽可能宽广的景别建立整体场景环境。
+- 尽可能减少摄像机位置的数量。
+"""
+
+human_prompt_decompose_visual_description = \
+"""
+<VISUAL_DESC>
+{visual_desc}
+</VISUAL_DESC>
+
+<CHARACTERS>
+{characters_str}
+</CHARACTERS>
+"""
+
+class StoryboardCreator:
+
+    def __init__(self) -> None:
+        pass
+
+    def design_storyboard(
+        self,
+        script: str,
+        characters: str,
+        user_requirement: Optional[str] = None
+    ):
+
+        user_prompt = human_prompt_design_storyboard.format(
+            script_str=script,
+            characters_str=characters,
+            user_requirement_str=user_requirement
+        )
+
+        system_prompt = system_prompt_design_storyboard
+
+        response = media_captioner.generate_text_understanding(
+            system_prompt=system_prompt,
+            user_prompt=user_prompt
+        )
+
+        response = string_to_json(response)
+
+        save_json_file(response, "storyboard.json")
+
+        return response
+
+    def decompose_visual_description(
+        self,
+        shot_brief_desc: str,
+        characters: str
+    ):
+
+        user_prompt = human_prompt_decompose_visual_description.format(
+            visual_desc=shot_brief_desc,
+            characters_str=characters
+        )
+
+        system_prompt = system_prompt_decompose_visual_description
+
+        response = media_captioner.generate_text_understanding(
+            system_prompt=system_prompt,
+            user_prompt=user_prompt
+        )
+
+        response = string_to_json(response)
+        save_json_file(response, "visual_description_decomposition.json")
+
+        return response
+
+    def create_storyboard(
+        self,
+        script: str,
+        characters: str,
+        user_requirement: Optional[str] = None
+    ):
+        # 为同一场景设计分镜
+        storyboard = self.design_storyboard(
+            script=script,
+            characters=characters,
+            user_requirement=user_requirement
+        )
+
+        # 拆解每个分镜的视觉描述(并行执行)
+        total_items = len(storyboard["storyboard"])
+        print(f"开始并行处理 {total_items} 个分镜项的视觉描述...")
+        
+        def process_item(item):
+            """处理单个分镜项的函数"""
+            item_idx = item.get("idx", "unknown")
+            print(f"处理分镜项 {item_idx}...")
+            decomposed_visual_desc = self.decompose_visual_description(
+                shot_brief_desc=item["visual_desc"],
+                characters=characters
+            )
+            print(f"分镜项 {item_idx} 处理完成")
+            return item, decomposed_visual_desc
+        
+        # 使用线程池并行处理所有分镜项
+        # max_workers 限制为最多10个线程,避免过多并发请求
+        max_workers = min(total_items, 10)
+        completed_count = 0
+        
+        with ThreadPoolExecutor(max_workers=max_workers) as executor:
+            # 提交所有任务
+            future_to_item = {
+                executor.submit(process_item, item): item 
+                for item in storyboard["storyboard"]
+            }
+            
+            # 收集结果并更新原始数据
+            for future in as_completed(future_to_item):
+                try:
+                    item, decomposed_visual_desc = future.result()
+                    item |= decomposed_visual_desc
+                    completed_count += 1
+                    print(f"进度: {completed_count}/{total_items} 个分镜项已完成")
+                except Exception as e:
+                    original_item = future_to_item[future]
+                    item_idx = original_item.get("idx", "unknown")
+                    print(f"处理分镜项 {item_idx} 时发生错误: {e}")
+                    raise
+        
+        print(f"所有 {total_items} 个分镜项的视觉描述处理完成")
+
+        save_json_file(storyboard, "storyboard.json")
+
+        return storyboard
+
+storyboard_creator = StoryboardCreator()
+
+if __name__ == "__main__":  
+    
+
+    with open("story_script.json", "r") as f:
+        story_script = json.load(f)
+
+    script = story_script["script"][0]
+
+    with open("characters.json", "r") as f:
+        characters = json.load(f)
+
+    # storyboard_creator.design_storyboard(
+    #     script=script,
+    #     characters=str(characters),
+    #     user_requirement=""
+    # )
+
+    with open("./output.json", "r") as f:
+        visual_description = json.load(f)
+
+    # storyboard = visual_description["storyboard"][0]["visual_desc"]
+
+    # storyboard_creator.decompose_visual_description(
+    #     shot_brief_desc=storyboard,
+    #     characters=str(characters)
+    # )
+
+    storyboard = storyboard_creator.create_storyboard(
+        script=script,
+        characters=str(characters),
+        user_requirement=""
+    )

+ 405 - 0
modules/media_generate/media_generator.py

@@ -0,0 +1,405 @@
+import os
+import time
+import requests
+import json
+import threading
+import asyncio
+import aiohttp
+from typing import Optional, Dict, Callable
+from dotenv import load_dotenv
+from utils.tools import encode_image, download_image, download_video
+from utils.upload import upload_file_to_tos
+from utils.logger_config import setup_logger
+
+load_dotenv()
+
+logger = setup_logger(__name__)
+
+class ArkImageGenerator:
+    """Ark 图片生成 API 封装类"""
+    
+    def __init__(
+        self,
+        auth_token: str = None,
+        model: str = "doubao-seedream-4-0-250828",
+        sequential_generation: str = "disabled",
+        response_format: str = "url",
+        stream: bool = False,
+        watermark: bool = True,
+        timeout: int = 120
+    ):
+        """
+        初始化图片生成器
+        
+        参数:
+            auth_token: 认证令牌(Bearer Token)
+            model: 模型名称(固定配置)
+            sequential_generation: 序列生成开关(固定配置)
+            response_format: 响应格式(固定配置)
+            stream: 流式响应开关(固定配置)
+            watermark: 水印开关(固定配置)
+            timeout: 请求超时时间(秒)
+        """
+        self.api_url = "https://ark.cn-beijing.volces.com/api/v3/images/generations"
+
+        if not auth_token:
+            auth_token = os.getenv("ARK_API_KEY")
+
+        self.headers = {
+            "Content-Type": "application/json",
+            "Authorization": f"Bearer {auth_token}"
+        }
+        # 固定配置参数
+        self.config = {
+            "model": model,
+            "sequential_image_generation": sequential_generation,
+            "response_format": response_format,
+            "stream": stream,
+            "watermark": watermark
+        }
+        self.timeout = timeout
+
+    async def generate_without_refer(self, prompt: str, filename: str, size: str = "1440x2560") -> Optional[Dict]:
+        if not prompt:
+            logger.info("错误:prompt不能为空")
+            return None
+
+        payload = {
+            **self.config,
+            "prompt": prompt,
+            "size": size
+        }
+        try:
+            # 使用 aiohttp 进行异步请求
+            async with aiohttp.ClientSession() as session:
+                async with session.post(
+                    url=self.api_url,
+                    headers=self.headers,
+                    json=payload,
+                    timeout=self.timeout
+                ) as response:
+                    response.raise_for_status()
+                    result_data = await response.json()
+                    result_image = result_data["data"][0]["url"]
+                    output_path = "./output/" + filename
+                    download_image(result_image, output_path)
+                    return result_image
+        
+        except Exception as e:
+            logger.info(f"错误:生成图片时发生异常:{e}")
+            return None
+
+    def generate(self, prompt: str, image_url: str, filename: str, size: str = "1440x2560") -> Optional[Dict]:
+        # 验证必填参数
+        if not prompt or not image_url:
+            logger.info("错误:prompt和image_url不能为空")
+            return None
+
+        # 如果image_url为图片路径,则编码为base64格式
+        reference_image = encode_image(image_url) if "http" not in image_url else image_url
+        
+        # 构建请求体(合并固定配置和动态参数)
+        payload = {
+            **self.config,
+            "prompt": prompt,
+            "image": reference_image,
+            "size": size
+        }
+        
+        try:
+            response = requests.post(
+                url=self.api_url,
+                headers=self.headers,
+                data=json.dumps(payload),
+                timeout=self.timeout
+            )
+            response.raise_for_status()
+
+            result_image = response.json()["data"][0]["url"]
+            output_path = "./output/" + filename
+            download_image(result_image, output_path)
+            return result_image
+        
+        except requests.exceptions.Timeout:
+            logger.info(f"错误:请求超时({self.timeout}秒)")
+        except requests.exceptions.ConnectionError:
+            logger.info("错误:网络连接失败,请检查API地址")
+        except requests.exceptions.HTTPError as e:
+            logger.info(f"错误:HTTP请求失败(状态码{response.status_code}):{e}")
+            if response.text:
+                logger.info(f"API错误详情:{response.text}")
+        except json.JSONDecodeError:
+            logger.info("错误:响应内容不是合法JSON")
+        except Exception as e:
+            logger.info(f"错误:生成图片时发生异常:{e}")
+        
+        return None
+
+class ArkVideoGenerator:
+    """Ark 图生视频 API 封装类,支持通过参考图和文本描述生成视频"""
+    
+    def __init__(
+        self,
+        auth_token: str = None,
+        model: str = "doubao-seedance-1-0-pro-250528",
+        timeout: int = 60,
+        poll_interval: int = 5,  # 轮询间隔(秒)
+        max_poll_time: int = 500  # 最大轮询总时间(秒)
+    ):
+        # 固定 API 端点
+        self.api_url = "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks"
+
+        if not auth_token:
+            auth_token = os.getenv("ARK_API_KEY")
+        # 固定请求头
+        self.headers = {
+            "Content-Type": "application/json",
+            "Authorization": f"Bearer {auth_token}"
+        }
+        # 固定配置参数(模型、超时)
+        self.model = model
+        self.timeout = timeout
+        self.poll_interval = poll_interval
+        self.max_poll_time = max_poll_time
+
+    def create_video_task(
+        self,
+        prompt: str,
+        image_url: str,
+        gen_params: str = ""
+    ) -> Optional[Dict]:
+        # 1. 验证动态参数合法性
+        if not prompt.strip():
+            logger.info("错误:文本描述(text_prompt)不能为空")
+            return None
+        if not image_url.strip():
+            logger.info("错误:参考图 URL(reference_image_url)不能为空")
+            return None
+
+        reference_image = upload_file_to_tos(image_url) if "http" not in image_url else image_url
+        logger.info(f"视频生成提示词: {prompt + gen_params}")
+        logger.info(f"视频生成参考图片: {reference_image}")
+        # refernece_image = encode_image(image_url) if "http" not in image_url else image_url
+        # 检查reference_image是否可访问
+        try:
+            head_response = requests.head(reference_image, timeout=5)
+            if head_response.status_code != 200:
+                logger.warning(f"参考图片URL可能无法访问,状态码: {head_response.status_code}")
+        except Exception as e:
+            logger.warning(f"验证参考图片URL可访问性时出错: {str(e)}")
+            return None
+        
+        # 2. 构建请求体(按 API 要求格式组装 content 列表)
+        payload = {
+            "model": self.model,
+            "content": [
+                {
+                    "type": "text",
+                    "text": prompt + gen_params
+                },
+                {
+                    "type": "image_url",
+                    "image_url": {
+                        "url": reference_image
+                    }
+                }
+            ]
+        }
+        
+        # 3. 发送 POST 请求并处理响应
+        try:
+            response = requests.post(
+                url=self.api_url,
+                headers=self.headers,
+                json=payload,
+                timeout=self.timeout
+            )
+            response.raise_for_status()
+            return response.json()
+        
+        except Exception as e:
+            logger.info(f"创建任务失败:{str(e)}")
+            return None
+
+    def query_video_task(self, task_id: str) -> Optional[Dict]:
+        """
+        新增:查询图生视频任务结果
+        
+        参数:
+            task_id: 视频任务 ID(从 create_video_task 响应中获取)
+        
+        返回:
+            任务结果详情(含视频状态、视频 URL 等)或 None
+        """
+        # 1. 验证任务 ID
+        if not task_id.strip():
+            logger.info("错误:任务 ID(task_id)不能为空")
+            return None
+        
+        # 2. 构建查询 URL(拼接 task_id)
+        query_url = f"{self.api_url}/{task_id}"
+        
+        # 3. 发送 GET 请求查询结果
+        try:
+            response = requests.get(
+                url=query_url,
+                headers=self.headers,
+                timeout=self.timeout
+            )
+            # 触发 HTTP 错误(如 404 任务不存在、401 令牌无效)
+            response.raise_for_status()
+            return response.json()
+
+        except Exception as e:
+            logger.info(f"查询任务失败:{str(e)}")
+            return None
+
+    def _background_poll(
+        self,
+        task_id: str,
+        filename: str,
+        callback: Callable[[str, str, Optional[Dict], Optional[str]], None]
+    ):
+        """
+        后台轮询任务状态的线程函数
+        :param task_id: 任务ID
+        :param callback: 回调函数,参数为 (task_id, 成功结果, 错误信息)
+        """
+        start_time = time.time()
+        while True:
+            elapsed = time.time() - start_time
+            if elapsed > self.max_poll_time:
+                callback(task_id, filename, None, f"任务超时(超过 {self.max_poll_time} 秒)")
+
+            # 查询任务状态
+            result = self.query_video_task(task_id)
+            if not result:
+                time.sleep(self.poll_interval)
+                continue
+            
+            # 解析状态
+            status = result.get("status", "").lower()
+            if status == "succeeded":
+                callback(task_id, filename, result, None)
+                return 
+            elif status == "failed":
+                error_msg = result.get("error", {}).get("message", "未知错误")
+                callback(task_id, filename, None, error_msg)
+                return
+            elif status in ["pending", "processing"]:
+                logger.info(f"任务 {task_id} 处理中({int(elapsed)}秒),状态:{status}")
+                time.sleep(self.poll_interval)
+            else:
+                logger.info(f"任务 {task_id} 未知状态:{status},继续等待...")
+                time.sleep(self.poll_interval)
+
+    def create_video_task_async(
+        self,
+        prompt: str,
+        image_url: str,
+        gen_params: str,
+        filename: str,
+        callback: Callable[[str, str, Optional[Dict], Optional[str]], None]
+    ) -> Optional[str]:
+        # 1. 提交任务
+        task_response = self.create_video_task(prompt, image_url, gen_params)
+        print('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx')
+        print(f"task_response: {task_response}")
+        if not task_response or "id" not in task_response:
+            logger.info("任务提交失败,无法启动后台轮询")
+            return None
+
+        task_id = task_response["id"]
+        logger.info(f"任务提交成功,task_id: {task_id},启动后台轮询...")
+
+        # 2. 启动后台线程轮询结果
+        poll_thread = threading.Thread(
+            target=self._background_poll,
+            args=(task_id, filename, callback),
+            daemon=True  # 守护线程:主程序退出时自动结束
+        )
+        poll_thread.start()
+
+        return task_id
+        
+# 1. 定义回调函数:任务完成/失败时会被调用
+def handle_video_result(task_id: str, filename, result: Optional[Dict], error: Optional[str]) -> None:
+    if error:
+        logger.info(f"\n任务 {task_id} 处理失败:{error}")
+    else:
+        video_url = result.get("content", {}).get("video_url")
+        output_path = "./output/" + filename
+        download_video(video_url, output_path) 
+        logger.info(f"生成视频已下载:{output_path}")
+                
+
+# API配置
+API_URL = os.getenv("AUDIO_GEN_API")
+
+def audio_generator(text, spk_audio="./data/audio/voice_07.wav", emo_audio="./data/audio/emo_sad.wav"):
+    """调用TTS API生成语音"""
+    payload = {
+        "text": text,
+        "spk_audio_prompt": spk_audio,
+        "emo_audio_prompt": emo_audio
+    }
+    
+    response = requests.post(API_URL, json=payload)
+    
+    if response.status_code == 200:
+        result = response.json()
+        if result["status"] == "success":
+            print(f"语音生成成功: {result['audio_file']}")
+            return result["audio_file"]
+    else:
+        print(f"请求失败: {response.text}")
+        return None
+
+
+image_generator = ArkImageGenerator()
+video_create = ArkVideoGenerator()
+    
+if __name__ == "__main__":
+    # 1. 初始化生成器(配置固定参数)
+    image_generator = ArkImageGenerator()
+    video_create = ArkVideoGenerator()
+    
+    # 2. 调用生成方法(仅传入动态参数)
+    # result = image_generator.generate(
+    #     prompt="狗狗在草地上追逐蒲公英",
+    #     image_url="https://ark-project.tos-cn-beijing.volces.com/doc_image/seedream4_imageToimage.png",
+    #     filename="1.jpg"
+    # )
+    
+    # logger.info(f"result:{result}")
+
+    # print(video_generator.query_video_task("cgt-20251022103303-9hgrr"))
+
+    # cgt-20251022101137-852fw cgt-20251022102536-kt8pj cgt-20251022103303-9hgrr
+
+    # task_id = video_create.create_video_task_async(
+    #     prompt="狗狗不停地在草地上跳跃",
+    #     image_url="https://testdgxcx-oss.gloria.com.cn/video-create/new_frame_scene0_camera0_shot1.png",
+    #     gen_params="",
+    #     filename="1.mp4",
+    #     callback=handle_video_result
+    # )
+
+    task_id = video_create.create_video_task(
+        prompt="狗狗不停地在草地上跳跃",
+        image_url="https://testdgxcx-oss.gloria.com.cn/video-create/new_frame_scene0_camera0_shot1.png",
+        gen_params=""
+    )
+    print(f"task_id: {task_id}")
+
+    if task_id:
+        print("\n主流程:任务已提交,开始执行其他操作...")
+        for i in range(10):
+            print(f"主流程:正在执行第 {i+1} 步操作...")
+            time.sleep(1)  # 模拟主流程耗时操作
+        print("主流程:所有操作执行完毕,等待后台任务结果(若未完成)...")
+        
+        # 防止主程序提前退出(实际生产环境可能有其他阻塞逻辑)
+        # 这里仅为演示:等待所有后台线程完成
+        while threading.active_count() > 1:
+            time.sleep(1)

+ 371 - 0
modules/media_process/media_processor.py

@@ -0,0 +1,371 @@
+import os
+import cv2
+from scenedetect import open_video, SceneManager
+from scenedetect.detectors import ContentDetector    
+from moviepy.editor import VideoFileClip, concatenate_videoclips
+from moviepy.video.io.ffmpeg_tools import ffmpeg_extract_subclip
+
+from typing import List, Tuple, Optional
+
+from utils.logger_config import setup_logger
+
+logger = setup_logger(__name__)
+
+class VideoAudioProcessor:
+    def __init__(self, output_dir: str = "./output"):
+        """
+        Initialize VideoAudioProcessor
+        
+        Args:
+            output_dir: Directory to save processed files
+        """
+        self.output_dir = output_dir
+        self.stt_model = None # SenseVoiceTranscriber()
+        
+        # Create output directory if not exists
+        if output_dir:
+            os.makedirs(output_dir, exist_ok=True)
+        logger.info(f"Initialized VideoAudioProcessor with output directory: {output_dir}")
+
+    def extract_audio(self, video_path: str) -> Optional[str]:
+        """
+        Extract audio from video file
+        
+        Args:
+            video_path: Path to video file
+            
+        Returns:
+            str: Path to extracted audio file or None if failed
+        """
+        try:
+            if not os.path.exists(video_path):
+                logger.error(f"Video file not found: {video_path}")
+                return None
+                
+            # Generate output audio path
+            audio_filename = os.path.splitext(os.path.basename(video_path))[0] + ".wav"
+            audio_path = os.path.join(self.output_dir, audio_filename)
+            
+            # Extract audio using moviepy
+            logger.info(f"Extracting audio from video: {video_path}")
+            video = VideoFileClip(video_path)
+            audio = video.audio
+            audio.write_audiofile(audio_path)
+            
+            # Clean up
+            video.close()
+            audio.close()
+            
+            logger.info(f"Audio extracted successfully: {audio_path}")
+            return audio_path
+            
+        except Exception as e:
+            logger.error(f"Failed to extract audio: {str(e)}")
+            return None
+        
+    def detect_scenes(self, video_path: str, threshold: float = 25.0) -> List[str]:
+        """
+        Detect scenes in video
+        
+        Args:
+            video_path: Path to video file
+            threshold: Threshold for scene detection
+        Returns:
+            List[str]: List of scene start and end timecode
+        """
+        try:
+            if not os.path.exists(video_path):
+                logger.error(f"Video file not found: {video_path}")
+                return []
+            
+            # Detect scenes
+            video = open_video(video_path)
+            scene_manager = SceneManager()
+            scene_manager.add_detector(ContentDetector(threshold=threshold))
+            scene_manager.detect_scenes(video)
+            scene_list = scene_manager.get_scene_list()
+
+            logger.info(f"Detected {len(scene_list)} scenes")
+            return scene_list
+        
+        except Exception as e:
+            logger.error(f"Failed to detect scenes: {str(e)}")
+            return []
+        
+    def extract_frames(self, video_path: str, interval: float = 1.0) -> List[str]:
+        """
+        Extract frames from video at specified interval
+        
+        Args:
+            video_path: Path to video file
+            interval: Time interval between frames in seconds
+            
+        Returns:
+            List[str]: List of paths to extracted frame images
+        """
+        try:
+            if not os.path.exists(video_path):
+                logger.error(f"Video file not found: {video_path}")
+                return []
+                
+            # Create frames directory
+            video_name = os.path.splitext(os.path.basename(video_path))[0]
+            frames_dir = os.path.join(self.output_dir, f"{video_name}_frames")
+            os.makedirs(frames_dir, exist_ok=True)
+            
+            # Open video file
+            cap = cv2.VideoCapture(video_path)
+            if not cap.isOpened():
+                logger.error("Failed to open video file")
+                return []
+            
+            # Get video properties
+            fps = cap.get(cv2.CAP_PROP_FPS)
+            frame_interval = int(fps * interval)
+            
+            frame_paths = []
+            frame_count = 0
+            frame_saved = 0
+            
+            logger.info(f"Extracting frames from video: {video_path}")
+            while cap.isOpened():
+                ret, frame = cap.read()
+                if not ret:
+                    break
+                    
+                # Save frame at specified interval
+                if frame_count % frame_interval == 0:
+                    frame_path = os.path.join(frames_dir, f"frame_{frame_saved:04d}.jpg")
+                    cv2.imwrite(frame_path, frame)
+                    frame_paths.append(frame_path)
+                    frame_saved += 1
+                    
+                frame_count += 1
+            
+            # Clean up
+            cap.release()
+            
+            logger.info(f"Extracted {len(frame_paths)} frames")
+            return frame_paths
+            
+        except Exception as e:
+            logger.error(f"Failed to extract frames: {str(e)}")
+            return []
+
+    def cut_video(self, input_path: str, start_time: float, end_time: float, output_name: Optional[str] = None,
+                 output_path: Optional[str] = None) -> Optional[str]:
+        """
+        Cut video file to specified time range
+        
+        Args:
+            input_path: Path to input video file
+            start_time: Start time in seconds
+            end_time: End time in seconds
+            output_path: Path to save output video file. If None, will generate one based on input path
+            
+        Returns:
+            str: Path to output video file or None if failed
+        """
+        try:
+            # Validate input file
+            if not os.path.exists(input_path):
+                logger.error(f"Input video file not found: {input_path}")
+                return None
+                
+            # Validate time range
+            if start_time < 0 or end_time <= start_time:
+                logger.error(f"Invalid time range: start={start_time}, end={end_time}")
+                return None
+                
+            # Generate output path if not provided
+            if output_path is None:
+                if output_name is None:
+                    filename = os.path.splitext(os.path.basename(input_path))[0]
+                    output_path = os.path.join(
+                        self.output_dir + "/clip_files/", 
+                            f"{filename}_cut_{int(start_time)}s_{int(end_time)}s.mp4"
+                        )
+                else:
+                    output_path = os.path.join(
+                        self.output_dir + "/clip_files/", 
+                        output_name
+                    )
+        
+            
+            # Ensure output directory exists
+            os.makedirs(os.path.dirname(output_path), exist_ok=True)
+
+            # 将毫秒转换为秒
+            start_time = start_time / 1000
+            end_time = end_time / 1000
+            
+            # Cut video using ffmpeg
+            logger.info(f"Cutting video from {start_time}s to {end_time}s: {output_path}")
+            ffmpeg_extract_subclip(input_path, start_time, end_time, targetname=output_path)
+            
+            if os.path.exists(output_path):
+                logger.info(f"Video cut successfully: {output_path}")
+                return output_path
+            else:
+                logger.error("Failed to create output video file")
+                return None
+                
+        except Exception as e:
+            logger.error(f"Failed to cut video: {str(e)}")
+            return None
+
+    def process_video(self, video_path: str, extract_audio: bool = True, extract_frames: bool = True, 
+                     frame_interval: float = 1.0, cut_video: bool = False,
+                     start_time: Optional[float] = None, end_time: Optional[float] = None) -> Tuple[Optional[str], Optional[str], List[str]]:
+        """
+        Process video file: cut video, extract audio, perform STT, and extract frames
+        
+        Args:
+            video_path: Path to video file
+            extract_audio: Whether to extract audio
+            extract_frames: Whether to extract frames
+            frame_interval: Time interval between frames in seconds
+            cut_video: Whether to cut video
+            start_time: Start time for video cutting in seconds
+            end_time: End time for video cutting in seconds
+            
+        Returns:
+            Tuple containing:
+            - Path to extracted audio file (or None)
+            - Transcribed text (or None)
+            - List of paths to extracted frames
+        """
+        audio_path = None
+        transcript = None
+        frame_paths = []
+        
+        try:
+            # Cut video if requested
+            processing_path = video_path
+            if cut_video and start_time is not None and end_time is not None:
+                cut_path = self.cut_video(video_path, start_time, end_time)
+                if cut_path:
+                    processing_path = cut_path
+                else:
+                    logger.warning("Video cutting failed, proceeding with original video")
+            
+            # Extract audio if requested
+            if extract_audio:
+                audio_path = self.extract_audio(processing_path)
+                if audio_path:
+                    # Perform STT on extracted audio
+                    transcript = self.stt_model.transcribe(audio_path)
+            
+            # Extract frames if requested
+            if extract_frames:
+                frame_paths = self.extract_frames(processing_path, frame_interval)
+            
+            return audio_path, transcript, frame_paths
+            
+        except Exception as e:
+            logger.error(f"Failed to process video: {str(e)}")
+            return audio_path, transcript, frame_paths
+
+    def process_audio(self, audio_path: str) -> Optional[str]:
+        """
+        Process audio file using STT
+        
+        Args:
+            audio_path: Path to audio file
+            
+        Returns:
+            str: Transcribed text or None if failed
+        """
+        try:
+            if not os.path.exists(audio_path):
+                logger.error(f"Audio file not found: {audio_path}")
+                return None
+                
+            return self.stt_model.transcribe(audio_path)
+            
+        except Exception as e:
+            logger.error(f"Failed to process audio: {str(e)}")
+            return None
+
+    def concat_videos(self, video_paths: List[str], output_path: str = None) -> Optional[str]:
+        """
+        Concatenate multiple video files into a single video file
+        
+        Args:
+            video_paths: List of paths to video files to concatenate
+            output_filename: Name of the output video file. If None, will generate one
+            
+        Returns:
+            str: Path to output concatenated video file or None if failed
+        """
+        try:
+            # Validate input
+            if not video_paths:
+                logger.error("Empty video paths list provided")
+                return None
+            
+            # Convert output_path to string if it's a Path object
+            if output_path is not None:
+                output_path = str(output_path)
+            else:
+                logger.error("Output path is required")
+                return None
+                
+            # Check if all input files exist
+            for video_path in video_paths:
+                if not os.path.exists(video_path):
+                    logger.error(f"Video file not found: {video_path}")
+                    return None
+            
+            # # Generate output filename if not provided
+            # if output_filename is None:
+            #     import time
+            #     timestamp = int(time.time())
+            #     output_filename = f"concatenated_video_{timestamp}.mp4"
+            
+            # # Generate full output path
+            # output_path = os.path.join(self.output_dir, output_filename)
+            
+            # Load all video clips
+            logger.info(f"Loading {len(video_paths)} video clips")
+            video_clips = []
+            try:
+                for video_path in video_paths:
+                    clip = VideoFileClip(video_path)
+                    video_clips.append(clip)
+                
+                # Concatenate video clips
+                logger.info("Concatenating video clips")
+                final_clip = concatenate_videoclips(video_clips, method="compose")
+                
+                # Write output video
+                logger.info(f"Writing concatenated video to: {output_path}")
+                final_clip.write_videofile(output_path)
+                
+                logger.info("Video concatenation completed successfully")
+                return output_path
+                
+            finally:
+                # Clean up resources
+                for clip in video_clips:
+                    clip.close()
+                
+        except Exception as e:
+            logger.error(f"Failed to concatenate videos: {str(e)}")
+            return None
+
+media_processor = VideoAudioProcessor()
+if __name__ == "__main__":
+    # Initialize processor
+    processor = VideoAudioProcessor("./output/room/")
+    
+    
+    # Test video concatenation
+    print("\nTesting video concatenation:")
+    video_segments = [
+        "./test_data/sample_video1.mp4",
+        "./test_data/sample_video2.mp4",
+        "./test_data/sample_video3.mp4"
+    ]
+    concatenated_video = processor.concat_videos(video_segments, "final_video.mp4")
+    print(f"Concatenated video path: {concatenated_video}")

+ 671 - 0
modules/media_understanding/media_captioner.py

@@ -0,0 +1,671 @@
+import os
+import base64
+import io
+import asyncio
+import aiohttp
+import time
+import functools
+from concurrent.futures import ThreadPoolExecutor
+from PIL import Image
+from typing import Optional, Dict, Any, Literal, Union, List, Callable
+from volcenginesdkarkruntime import Ark
+from utils.logger_config import setup_logger
+from utils.config_manager import ConfigManager
+from dotenv import load_dotenv
+
+# 加载.env文件
+load_dotenv()
+
+logger = setup_logger(__name__)
+
+def async_performance_monitor(func: Callable):
+    """异步方法性能监控装饰器"""
+    @functools.wraps(func)
+    async def wrapper(*args, **kwargs):
+        start_time = time.time()
+        try:
+            result = await func(*args, **kwargs)
+            end_time = time.time()
+            execution_time = end_time - start_time
+            logger.info(f"{func.__name__} completed in {execution_time:.2f} seconds")
+            return result
+        except Exception as e:
+            end_time = time.time()
+            execution_time = end_time - start_time
+            logger.error(f"{func.__name__} failed after {execution_time:.2f} seconds: {str(e)}")
+            raise
+    return wrapper
+
+def sync_performance_monitor(func: Callable):
+    """同步方法性能监控装饰器"""
+    @functools.wraps(func)
+    def wrapper(*args, **kwargs):
+        start_time = time.time()
+        try:
+            result = func(*args, **kwargs)
+            end_time = time.time()
+            execution_time = end_time - start_time
+            logger.info(f"{func.__name__} completed in {execution_time:.2f} seconds")
+            return result
+        except Exception as e:
+            end_time = time.time()
+            execution_time = end_time - start_time
+            logger.error(f"{func.__name__} failed after {execution_time:.2f} seconds: {str(e)}")
+            raise
+    return wrapper
+
+class MediaCaptioner:
+    """媒体描述生成器,使用火山引擎API进行视频、图像和文本内容理解"""
+    
+    def __init__(self, api_key: Optional[str] = None, 
+                 base_url: str = "https://ark.cn-beijing.volces.com/api/v3",
+                 model: str = "doubao-seed-1-6-250615",
+                 config_path: Optional[str] = None):
+        """
+        初始化媒体描述生成器
+        
+        Args:
+            api_key: 火山引擎API密钥,如果为None则从环境变量获取
+            base_url: API基础URL
+            model: 使用的模型ID
+            config_path: 提示词配置文件路径
+        """
+        try:
+            self.api_key = api_key or os.getenv("VOLC_API_KEY")
+            if not self.api_key:
+                raise ValueError("API key must be provided either through constructor or environment variable VOLC_API_KEY")
+            
+            self.client = Ark(
+                api_key=self.api_key,
+                base_url=base_url
+            )
+            self.base_url = base_url
+            self.model = model
+            self.config_manager = ConfigManager(config_path)
+            logger.info(f"Initialized MediaCaptioner with model: {model}")
+            
+        except Exception as e:
+            logger.error(f"Failed to initialize MediaCaptioner: {str(e)}")
+            raise
+
+    @sync_performance_monitor
+    def _encode_video(self, video_path: str) -> str:
+        """
+        将视频文件转换为base64编码
+        
+        Args:
+            video_path: 视频文件路径
+            
+        Returns:
+            str: base64编码的视频数据
+            
+        Raises:
+            FileNotFoundError: 视频文件不存在
+            IOError: 读取文件失败
+        """
+        if not os.path.exists(video_path):
+            raise FileNotFoundError(f"Video file not found: {video_path}")
+            
+        with open(video_path, "rb") as f:
+            return base64.b64encode(f.read()).decode("utf-8")
+
+    @sync_performance_monitor
+    def _encode_image(self, image_path: str) -> str:
+        """
+        将图片文件转换为base64编码
+        
+        Args:
+            image_path: 图片文件路径
+            
+        Returns:
+            str: base64编码的图片数据
+            
+        Raises:
+            FileNotFoundError: 图片文件不存在
+            IOError: 读取或处理图片失败
+        """
+        if not os.path.exists(image_path):
+            raise FileNotFoundError(f"Image file not found: {image_path}")
+            
+        with Image.open(image_path) as img:
+            buffered = io.BytesIO()
+            img.save(buffered, format="JPEG")
+            return base64.b64encode(buffered.getvalue()).decode("utf-8")
+
+    def generate_video_caption(self, 
+                             video_path: str, 
+                             prompt_type: str = "caption",
+                             scenario: Optional[str] = None,
+                             fps: int = 2,
+                             context_info: Optional[str] = None) -> Optional[str]:
+        """
+        生成视频描述的同步包装器
+        
+        Args:
+            video_path: 视频文件路径
+            prompt_type: 提示词类型
+            scenario: 场景类型
+            fps: 视频采样帧率
+            
+        Returns:
+            str: 视频描述,如果处理失败则返回None
+        """
+        loop = asyncio.get_event_loop()
+        return loop.run_until_complete(
+            self._process_video_async(
+                file_path=video_path,
+                prompt_type=prompt_type,
+                scenario=scenario,
+                fps=fps,
+                context_info=context_info
+            )
+        )
+
+    async def generate_video_caption_async(self, 
+                                         video_path: str, 
+                                         prompt_type: str = "caption",
+                                         scenario: Optional[str] = None,
+                                         fps: int = 2,
+                                         context_info: Optional[str] = None) -> Optional[str]:
+        """
+        异步生成视频描述
+        
+        Args:
+            video_path: 视频文件路径
+            prompt_type: 提示词类型
+            scenario: 场景类型
+            fps: 视频采样帧率
+            
+        Returns:
+            str: 视频描述,如果处理失败则返回None
+        """
+        return await self._process_video_async(
+            file_path=video_path,
+            prompt_type=prompt_type,
+            scenario=scenario,
+            fps=fps,
+            context_info=context_info
+        )
+
+    def generate_image_caption(self, 
+                             image_path: str, 
+                             prompt_type: str = "caption",
+                             scenario: Optional[str] = None,
+                             context_info: Optional[str] = None) -> Optional[str]:
+        """
+        生成图片描述的同步包装器
+        
+        Args:
+            image_path: 图片文件路径
+            prompt_type: 提示词类型
+            scenario: 场景类型
+            
+        Returns:
+            str: 图片描述,如果处理失败则返回None
+        """
+        loop = asyncio.get_event_loop()
+        return loop.run_until_complete(
+            self._process_image_async(
+                file_path=image_path,
+                prompt_type=prompt_type,
+                scenario=scenario,
+                context_info=context_info
+            )
+        )
+
+    async def generate_image_caption_async(self, 
+                                         image_path: str, 
+                                         prompt_type: str = "caption",
+                                         scenario: Optional[str] = None,
+                                         context_info: Optional[str] = None) -> Optional[str]:
+        """
+        异步生成图片描述
+        
+        Args:
+            image_path: 图片文件路径
+            prompt_type: 提示词类型
+            scenario: 场景类型
+            
+        Returns:
+            str: 图片描述,如果处理失败则返回None
+        """
+        return await self._process_image_async(
+            file_path=image_path,
+            prompt_type=prompt_type,
+            scenario=scenario,
+            context_info=context_info
+        )
+
+    def generate_text_understanding(self,
+                                  text: str,
+                                  prompt_type: str = "summary",
+                                  scenario: Optional[str] = None,
+                                  max_length: Optional[int] = None,
+                                  context_info: Optional[str] = None) -> Optional[str]:
+        """
+        生成文本理解结果的同步包装器
+        
+        Args:
+            text: 需要理解的文本内容
+            prompt_type: 提示词类型
+            scenario: 场景类型
+            max_length: 最大输出长度
+            
+        Returns:
+            str: 文本理解结果,如果处理失败则返回None
+        """
+        loop = asyncio.get_event_loop()
+        return loop.run_until_complete(
+            self._process_text_async(
+                text=text,
+                prompt_type=prompt_type,
+                scenario=scenario,
+                max_length=max_length,
+                context_info=context_info
+            )
+        )
+
+    async def generate_text_understanding_async(self,
+                                              text: str,
+                                              prompt_type: str = "summary",
+                                              scenario: Optional[str] = None,
+                                              max_length: Optional[int] = None,
+                                              context_info: Optional[str] = None) -> Optional[str]:
+        """
+        异步生成文本理解结果
+        
+        Args:
+            text: 需要理解的文本内容
+            prompt_type: 提示词类型
+            scenario: 场景类型
+            max_length: 最大输出长度
+            
+        Returns:
+            str: 文本理解结果,如果处理失败则返回None
+        """
+        return await self._process_text_async(
+            text=text,
+            prompt_type=prompt_type,
+            scenario=scenario,
+            max_length=max_length,
+            context_info=context_info
+        )
+
+    def generate_multi_aspect_understanding(self,
+                                          text: str,
+                                          prompt_types: List[str],
+                                          scenario: Optional[str] = None) -> Dict[str, Optional[str]]:
+        """
+        从多个角度生成文本理解结果的同步包装器
+        
+        Args:
+            text: 需要理解的文本内容
+            prompt_types: 提示词类型列表
+            scenario: 场景类型
+            
+        Returns:
+            Dict[str, Optional[str]]: 提示词类型到理解结果的映射
+        """
+        loop = asyncio.get_event_loop()
+        return loop.run_until_complete(
+            self.generate_multi_aspect_understanding_async(
+                text=text,
+                prompt_types=prompt_types,
+                scenario=scenario
+            )
+        )
+
+    async def generate_multi_aspect_understanding_async(self,
+                                                      text: str,
+                                                      prompt_types: List[str],
+                                                      scenario: Optional[str] = None,
+                                                      context_info: Optional[str] = None) -> Dict[str, Optional[str]]:
+        """
+        异步从多个角度生成文本理解结果
+        
+        Args:
+            text: 需要理解的文本内容
+            prompt_types: 提示词类型列表
+            scenario: 场景类型
+            
+        Returns:
+            Dict[str, Optional[str]]: 提示词类型到理解结果的映射
+        """
+        tasks = [
+            self._process_text_async(
+                text=text,
+                prompt_type=prompt_type,
+                scenario=scenario,
+                context_info=context_info
+            )
+            for prompt_type in prompt_types
+        ]
+        
+        results = await asyncio.gather(*tasks)
+        return dict(zip(prompt_types, results))
+
+    async def _make_api_request(self, 
+                              endpoint: str,
+                              payload: Dict[str, Any],
+                              timeout: int = 180) -> Dict[str, Any]:
+        """
+        发送API请求的通用方法
+        
+        Args:
+            endpoint: API端点
+            payload: 请求负载
+            timeout: 超时时间(秒)
+            
+        Returns:
+            Dict[str, Any]: API响应
+            
+        Raises:
+            aiohttp.ClientError: API请求失败
+            asyncio.TimeoutError: 请求超时
+        """
+        try:
+            async with aiohttp.ClientSession() as session:
+                async with session.post(
+                    f"{self.base_url}/{endpoint}",
+                    json=payload,
+                    headers={"Authorization": f"Bearer {self.api_key}"},
+                    timeout=timeout
+                ) as response:
+                    if response.status != 200:
+                        error_text = await response.text()
+                        raise aiohttp.ClientError(f"API request failed with status {response.status}: {error_text}")
+                    return await response.json()
+        except asyncio.TimeoutError:
+            logger.error(f"API request timed out after {timeout} seconds")
+            raise
+        except Exception as e:
+            logger.error(f"API request failed: {str(e)}")
+            raise
+
+    @async_performance_monitor
+    async def _process_video_async(self, file_path: str, prompt_type: str,
+                                 scenario: Optional[str] = None, fps: int = 2, context_info: Optional[str] = None) -> Optional[str]:
+        """异步处理视频文件"""
+        try:
+            # 在线程池中执行文件IO操作
+            loop = asyncio.get_event_loop()
+            with ThreadPoolExecutor() as pool:
+                base64_video = await loop.run_in_executor(
+                    pool, self._encode_video, file_path
+                )
+            
+            prompt = self.config_manager.get_prompt("video", prompt_type, scenario)
+
+            # 构建API请求
+            payload = {
+                "model": self.model,
+                "messages": [{
+                    "role": "system",
+                    "content": [
+                        {
+                            "type": "video_url",
+                            "video_url": {
+                                "url": f"data:video/mp4;base64,{base64_video}",
+                                "fps": fps
+                            }
+                        },
+                        {
+                            "type": "text",
+                            "text": prompt
+                        }
+                    ]
+                },
+                {
+                    "role": "user",
+                    "content": f"上下文信息:{context_info}"
+                }
+                ]
+            }
+            
+            # 发送API请求
+            response = await self._make_api_request("chat/completions", payload)
+            return response["choices"][0]["message"]["content"]
+                
+        except Exception as e:
+            logger.error(f"Failed to process video async: {str(e)}")
+            return None
+
+    @async_performance_monitor
+    async def _process_image_async(self, file_path: str, prompt_type: str,
+                                 scenario: Optional[str] = None, context_info: Optional[str] = None) -> Optional[str]:
+        """异步处理图片文件"""
+        try:
+            # 在线程池中执行文件IO操作
+            loop = asyncio.get_event_loop()
+            with ThreadPoolExecutor() as pool:
+                base64_image = await loop.run_in_executor(
+                    pool, self._encode_image, file_path
+                )
+            
+            prompt = self.config_manager.get_prompt("image", prompt_type, scenario)
+            
+            # 构建API请求
+            payload = {
+                "model": self.model,
+                "messages": [{
+                    "role": "system",
+                    "content": [
+                        {
+                            "type": "image_url",
+                            "image_url": {
+                                "url": f"data:image/jpeg;base64,{base64_image}"
+                            }
+                        },
+                        {
+                            "type": "text",
+                            "text": prompt
+                        }
+                    ]
+                },
+                {
+                    "role": "user",
+                    "content": f"上下文信息:{context_info}"
+                }
+                ]
+            }
+            
+            # 发送API请求
+            response = await self._make_api_request("chat/completions", payload)
+            return response["choices"][0]["message"]["content"]
+                
+        except Exception as e:
+            logger.error(f"Failed to process image async: {str(e)}")
+            return None
+
+    @async_performance_monitor
+    async def _process_text_async(self, text: str, prompt_type: str,
+                                scenario: Optional[str] = None,
+                                max_length: Optional[int] = None,
+                                context_info: Optional[str] = None) -> Optional[str]:
+        """异步处理文本内容"""
+        # try:
+        if not text.strip():
+            logger.error("Empty text provided")
+            return None
+            
+        prompt = self.config_manager.get_prompt("video", prompt_type, scenario)
+        
+        # 构建API请求
+        payload = {
+            "model": self.model,
+            "messages": [
+                {
+                    "role": "system",
+                    "content": prompt
+                },
+                {
+                    "role": "user",
+                    "content": text
+                },
+                {
+                    "role": "user",
+                    "content": f"上下文信息:{context_info}"
+                }
+            ],
+            "max_tokens": max_length if max_length else None
+        }
+        
+        # 发送API请求
+        response = await self._make_api_request("chat/completions", payload)
+        return response["choices"][0]["message"]["content"]
+                
+        # except Exception as e:
+        #     logger.error(f"Failed to process text async: {str(e)}")
+        #     return None
+
+    async def generate_batch_captions_async(self, 
+                                          files: Dict[str, Dict[str, Union[str, int]]],
+                                          scenario: Optional[str] = None,
+                                          max_concurrent: int = 5) -> Dict[str, Optional[str]]:
+        """
+        异步批量生成媒体描述
+        
+        Args:
+            files: 文件配置字典
+            scenario: 场景类型
+            max_concurrent: 最大并发数
+            
+        Returns:
+            Dict[str, Optional[str]]: 文件路径或标识符到描述的映射
+        """
+        results = {}
+        # 创建信号量控制并发
+        semaphore = asyncio.Semaphore(max_concurrent)
+        
+        async def process_single_file(file_path: str, config: Dict[str, Any]) -> tuple[str, Optional[str]]:
+            """处理单个文件的异步函数"""
+            async with semaphore:  # 使用信号量控制并发
+                try:
+                    media_type = config["type"]
+                    prompt_type = config.get("prompt_type", "caption" if media_type != "text" else "summary")
+                    
+                    if media_type == "video":
+                        fps = config.get("fps", 2)
+                        result = await self._process_video_async(
+                            file_path=file_path,
+                            prompt_type=prompt_type,
+                            scenario=scenario,
+                            fps=fps,
+                            context_info=config.get("context_info")
+                        )
+                    elif media_type == "image":
+                        result = await self._process_image_async(
+                            file_path=file_path,
+                            prompt_type=prompt_type,
+                            scenario=scenario,
+                            context_info=config.get("context_info")
+                        )
+                    elif media_type == "text":
+                        if "content" not in config:
+                            logger.error(f"Text content not provided for {file_path}")
+                            return file_path, None
+                        
+                        result = await self._process_text_async(
+                            text=config["content"],
+                            prompt_type=prompt_type,
+                            scenario=scenario,
+                            max_length=config.get("max_length"),
+                            context_info=config.get("context_info")
+                        )
+                    else:
+                        logger.warning(f"Unsupported media type: {media_type}")
+                        return file_path, None
+                    
+                    return file_path, result
+                    
+                except Exception as e:
+                    logger.error(f"Failed to process file {file_path}: {str(e)}")
+                    return file_path, None
+        
+        # 创建所有任务
+        tasks = [
+            process_single_file(file_path, config)
+            for file_path, config in files.items()
+        ]
+        
+        # 并行执行所有任务
+        completed_tasks = await asyncio.gather(*tasks)
+        
+        # 整理结果
+        results = dict(completed_tasks)
+        
+        return results
+
+    def generate_batch_captions(self, 
+                              files: Dict[str, Dict[str, Union[str, int]]],
+                              scenario: Optional[str] = None) -> Dict[str, Optional[str]]:
+        """
+        批量生成媒体描述的同步包装器
+        
+        Args:
+            files: 文件配置字典,格式为:
+                  {
+                      "file_path": {
+                          "type": "video"|"image"|"text",
+                          "prompt_type": str,  # 可选
+                          "fps": int,  # 仅视频可用
+                          "content": str,  # 仅文本类型需要
+                          "max_length": int  # 可选,仅文本类型可用
+                      }
+                  }
+            scenario: 场景类型
+            
+        Returns:
+            Dict[str, Optional[str]]: 文件路径或标识符到描述的映射
+        """
+        # 创建事件循环
+        loop = asyncio.get_event_loop()
+        # 运行异步方法
+        return loop.run_until_complete(
+            self.generate_batch_captions_async(files, scenario)
+        )
+
+media_captioner: MediaCaptioner = MediaCaptioner()
+
+if __name__ == "__main__":
+    async def main():
+        # 初始化
+        captioner = MediaCaptioner()
+
+        # 处理文本
+        text_content = """
+        近日,研究人员在深海发现了一种新的海洋生物物种。
+        这种生物具有独特的生物发光能力,可以在完全黑暗的环境中发出蓝绿色的光。
+        科学家们认为,这一发现对于了解深海生态系统具有重要意义。
+        """
+        
+        # 批量处理示例
+        files = {
+            "./test_data/sample_video.mp4": {
+                "type": "video",
+                "prompt_type": "caption",
+                "fps": 2
+            },
+            "./test_data/sample_image.jpg": {
+                "type": "image",
+                "prompt_type": "caption"
+            },
+            "text_sample": {
+                "type": "text",
+                "content": text_content,
+                "prompt_type": "summary",
+                "max_length": 200
+            }
+        }
+        
+        # 异步批量处理
+        results = await captioner.generate_batch_captions_async(
+            files, 
+            scenario="academic",
+            max_concurrent=5
+        )
+        
+        print("批量处理结果:", results)
+
+    # 运行异步主函数
+    asyncio.run(main())

+ 41 - 0
modules/media_understanding/media_understand.py

@@ -0,0 +1,41 @@
+import os
+from volcenginesdkarkruntime import Ark
+from utils.tools import encode_video
+from utils.upload import upload_file_to_tos
+from dotenv import load_dotenv
+
+load_dotenv()
+
+video_url = encode_video("./data/test.mp4")
+
+video_url = upload_file_to_tos("./data/raw_test.mp4")
+print(video_url)
+
+client = Ark(
+    base_url="https://ark.cn-beijing.volces.com/api/v3",
+    api_key=os.environ.get("ARK_API_KEY"),
+)
+
+# Non-streaming:
+print("----- image input request -----")
+completion = client.chat.completions.create(
+    model="doubao-seed-1-6-250615",
+    messages=[
+        {
+            "role": "user",
+            "content": [
+                {
+                    "type": "video_url",
+                    "video_url": {
+                        "url": video_url,
+                        "fps": 2
+                    },
+                },
+                {"type": "text", "text": "请解析视频内容"},
+            ],
+        }
+    ],
+    
+)
+print(completion.choices[0].message.content)
+

+ 18 - 0
output/characters.json

@@ -0,0 +1,18 @@
+{
+    "characters": [
+        {
+            "idx": 0,
+            "identifier_in_scene": "林薇",
+            "is_visible": true,
+            "static_features": "身材高挑匀称,五官精致,大眼睛,高鼻梁,皮肤白皙,锁骨清晰,手指修长",
+            "dynamic_features": "米色廓形风衣,焦糖色短靴,几何线条感手袋,焦糖色唇釉"
+        },
+        {
+            "idx": 1,
+            "identifier_in_scene": "陈奶奶",
+            "is_visible": true,
+            "static_features": "头发花白,脸上布满皱纹,眼角皱纹深邃,老花镜常滑至鼻尖,体型微驼,双手布满老年斑和皱纹,手指关节略显粗大",
+            "dynamic_features": "洗得发白的靛蓝布衫,老花镜,竹篮(内装彩色针织挂件:兔子、星星、小火车等)"
+        }
+    ]
+}

+ 7 - 0
output/script.json

@@ -0,0 +1,7 @@
+{
+    "script": [
+        "**外景. 愚园路街头 - 深秋午后**\n\n阳光透过梧桐枝叶,在柏油路上投下斑驳的碎金。秋风卷起几片枯叶,打着旋儿飘过。\n\n林薇\n(身着米色廓形风衣与焦糖色短靴,手提几何线条感手袋,步伐精准得像在走秀台)\n(眉头微蹙,右手无意识地摩挲着风衣腰带)\n\n(手机在口袋里震动,她停下脚步,掏出手机瞥了一眼屏幕,指尖在金属搭扣上用力按了按)\n\n林薇\n(对着空气轻嗤一声,声音低得几乎听不见)\n'高级感...到底什么才是有灵魂的高级感?'\n\n(她抬头望见前方路牌,上面'愚园路'三个字被阳光照得有些刺眼。脚步顿了顿,深吸一口气,转身朝路牌方向走去,风衣下摆扫过地面的落叶,发出沙沙声响。)",
+        "**外景. 愚园路转角 - 紧接上一场**\n\n一阵强风突然卷起满地落叶,形成小小的漩涡。\n\n陈奶奶\n(穿着洗得发白的靛蓝布衫,戴着老花镜,竹篮脱手倒地,里面的针织挂件散落一地)\n哎哟!\n\n(老人慌忙蹲下,双手在秋风中徒劳地抓向翻滚的毛线挂件——有兔子、星星、小火车,五颜六色的毛线在风里抖动。她的老花镜滑到鼻尖,镜片反射着碎光。)\n\n林薇\n(刚走到转角,脚步猛地停住。目光落在老人颤抖的手上,瞳孔微缩,仿佛看到什么熟悉的画面)\n\n(她快步上前,弯腰拾起滚到脚边的蓝色星星挂件。指尖触到毛线时,身体几不可察地一僵。)\n\n林薇\n(将星星递还给老人,声音比平时柔和)\n您没事吧?\n\n陈奶奶\n(接过星星,扶了扶眼镜,露出慈祥的笑)\n谢谢您,姑娘。这风太捣乱了,把给小远的礼物都吹散了。\n\n(她布满皱纹的手指轻轻捏着星星挂件,右角那个刻意织歪的小缺口在阳光下格外明显。)\n\n林薇\n(视线停留在缺口上,指尖不自觉地碰了碰)\n这是...您自己织的?\n\n陈奶奶\n(眼里泛起光,拿起星星晃了晃)\n是啊,小远在北方上大学,冬天冷。这个歪角是他小时候磕破的门牙,他总说奶奶织的星星和他一样'有点小缺陷才可爱'。\n\n(老人咯咯笑起来,眼角的皱纹像漾开的水波。林薇跟着笑了,嘴角的紧绷第一次松弛下来。)\n\n陈奶奶\n(从篮底摸出个浅灰色的小羊挂件,塞到林薇手里)\n这个送你,看你穿得这么精神,却好像有点冷。\n\n(林薇接过小羊,羊毛混纺的材质在掌心留下暖意,羊耳朵里藏着的一根金线闪了闪。她抬头,正对上老人温暖的目光,突然觉得喉咙有点发紧。)",
+        "**内景. 林薇的工作室 - 傍晚**\n\n工作室里,落地窗外华灯初上,霓虹灯光在白色墙壁上投下流动的光斑。设计稿散落一桌,上面画满凌厉的建筑线条。\n\n林薇\n(将浅灰色小羊挂件别在设计稿旁,退后两步凝视着。小羊的影子落在纸上,像一小团温暖的云。)\n\n(她重新拿起铅笔,笔尖落在纸上时,线条不再是笔直的切割,而是呈现出毛线般的起伏弧度。她画得很快,嘴角噙着浅浅的笑意。)\n\n(手机屏幕亮起,弹出一条消息。林薇拿起手机,屏幕光照亮她的脸,眼神柔软。)\n\n手机屏幕\n(特写)陈奶奶托邻居发来的消息:'小远收到兔子了,视频里说像奶奶抱着他。'\n\n林薇\n(指尖在屏幕上轻轻点了点,回复'替我祝小远生日快乐',然后将手机放回桌面。)\n\n(她转身看向落地窗,玻璃映出她的倒影——依旧是精致的风衣和焦糖色唇釉,但眼神里多了温度。她拿起笔,在设计稿角落写下一行小字。)\n\n设计稿角落\n(特写)'时尚是容器,里面要装着生活的暖光。'\n\n(林薇放下笔,拿起小羊挂件贴在脸颊,轻轻闭上眼,嘴角扬起满足的弧度。窗外的霓虹灯光在她身上流转,像给她镀了一层温柔的金边。)"
+    ]
+}

+ 45 - 0
output/story.txt

@@ -0,0 +1,45 @@
+# 街角的时光碎片
+
+## 目标受众与类型
+本故事面向全年龄段,属于都市生活类型
+
+## 故事梗概
+时尚设计师林薇在灵感枯竭的午后独自街头漫步,外表精致却内心疏离,偶然帮助一位散落手工针织品的老人,通过短暂交流发现平凡中的温暖与创作灵感,最终带着新的心境与设计构想返回工作室,理解了时尚不仅是表面的光鲜,更是情感的载体。
+
+## 主要角色介绍
+- 林薇:女,27岁,新锐时尚设计师。外表精致讲究,身着米色廓形风衣与焦糖色短靴,手提几何线条感手袋。专业上追求极致却陷入创作瓶颈,内心渴望突破却被"高级感"束缚,行动动机是寻找失落的设计灵感与生活温度。
+- 陈奶奶:女,72岁,退休教师,手工爱好者。穿着洗得发白的靛蓝布衫,戴着老花镜,随身携带装满手工针织挂件的竹篮。性格温和健谈,编织是为远方求学的孙子准备礼物,动机是传递思念与温暖。
+
+
+## 第一幕:流光中的独行
+深秋午后,梧桐叶在柏油路上铺成碎金地毯。林薇踩着3厘米的粗跟短靴,风衣下摆随步伐划出流畅弧线。她刚结束一场令人窒息的设计会议,甲方"不够高级"的评价像根细刺扎在心头——她的设计稿总被说"漂亮却没有灵魂"。
+
+手机在口袋里震动,是助理发来的面料样品清单。她瞥了一眼,指尖无意识摩挲着手袋上金属搭扣的冷硬棱角。街对面的咖啡馆飘出肉桂香,玻璃幕墙映出她的倒影:微卷的深棕色长发,恰到好处的焦糖色唇釉,连走路时风衣腰带的垂坠角度都像是经过计算。可这完美的倒影里,她却看不到一点温度。
+
+"又在赶稿?"闺蜜的语音消息跳出来,"别总把自己关在工作室,去愚园路走走吧,听说那边有老洋房改造的手作店。"林薇停下脚步,抬头望见路牌——正是愚园路。她深吸一口气,决定给自己半小时"偏离轨道"的时间。
+
+
+## 第二幕:散落的拼图
+转角处,一阵风突然卷起满地落叶。"哎哟!"一声轻呼刺破街景的宁静。林薇循声望去,一位白发老人正蹲在地上,手忙脚乱地捡拾散落的物件——是些巴掌大的针织挂件,兔子、星星、小火车,五颜六色的毛线在秋风里翻滚。
+
+老人的竹篮倒在一旁,镜片滑到鼻尖。林薇下意识想绕道,脑中却闪过奶奶生前织毛衣的样子——那时她总说"针脚里要藏着念想,穿的人才暖和"。这个念头让她顿住脚步,弯腰拾起滚到脚边的蓝色星星挂件。
+
+"谢谢您,姑娘。"老人扶了扶眼镜,露出慈祥的笑,"这风太捣乱了,把给小远的礼物都吹散了。"她布满皱纹的手指轻轻捏着挂件,毛线针脚细密均匀,星星的右角有个刻意织歪的小缺口。
+
+"这是...您自己织的?"林薇指尖触到毛线的温度,不同于她工作室里那些冰冷的化纤面料。
+
+"是啊,"老人眼里泛起光,"小远在北方上大学,冬天冷。这些挂在书包上,他看见就想起家了。那个歪角是他小时候磕破的门牙,他总说奶奶织的星星和他一样'有点小缺陷才可爱'。"老人咯咯笑起来,眼角的皱纹像漾开的水波。
+
+
+## 第三幕:暖意的回响
+林薇帮老人把最后一个兔子挂件放进竹篮,发现每个挂件背后都绣着极小的日期。"这是..."
+
+"他离家的日子,"老人指着兔子挂件,"今天是他生日,本想寄这个兔子去,结果着急出门就..."她突然从篮底摸出个浅灰色的小羊挂件,"这个送你吧,看你穿得这么精神,却好像有点冷。"
+
+林薇接过小羊,羊毛混纺的材质温暖柔软,羊耳朵里藏着一根细细的金线。她忽然想起自己最新系列的主题——"城市肌理",之前总纠结于建筑线条与金属光泽,此刻却明白:真正的肌理,是藏在针脚里的思念,是缺口里的笑意,是陌生人指尖传递的温度。
+
+"谢谢您,陈奶奶。"她第一次在陌生人面前卸下紧绷的嘴角,露出真心的微笑。
+
+傍晚回到工作室,林薇将小羊挂件别在设计稿旁。当她重新拿起铅笔,线条不再追求凌厉的切割感,而是像毛线般有了起伏的呼吸。手机亮起,是陈奶奶托邻居发来的消息:"小远收到兔子了,视频里说像奶奶抱着他。"
+
+窗外华灯初上,林薇望着玻璃上自己的影子——依旧时尚,却多了双有温度的眼睛。她在设计稿角落写下:"时尚是容器,里面要装着生活的暖光。"

+ 432 - 0
pipeline/script2video_pipeline.py

@@ -0,0 +1,432 @@
+import os
+import json
+import time
+import asyncio
+from typing import Optional
+from utils.tools import (
+    string_to_json,
+    save_json_file,
+    setup_logger,
+    efficient_sort
+)
+
+from tools.banana_pro import generate_image_from_prompt_and_images
+from tools.text_generator import media_captioner
+from tools.image_generator import image_generator
+from tools.video_generator import video_generator
+from tools.video_composer import video_composer, concat_videos
+from mcps.story_create import story_creator
+from mcps.character_extract import character_extractor
+from mcps.character_portraits_generate import character_portraits_generator
+from mcps.storyboard_create import storyboard_creator
+from mcps.camera_tree import camera_tree_creator
+from mcps.reference_image_select import reference_image_selector
+
+logger = setup_logger(__name__)
+
+class Script2VideoPipeline:
+
+    def __init__(
+        self
+    ):
+        pass
+
+    def video_create_pipeline(
+        self,
+        idea: str,
+        user_requirement: Optional[str] = None,
+        style: Optional[str] = None,
+    ):
+
+        # 1. 创建故事
+        logger.info("Creating story...")
+        if os.path.exists("./output/story.txt"):
+            with open("./output/story.txt", "r", encoding='utf-8') as f:
+                story = f.read()
+        else:
+            story = story_creator.develop_story(
+                idea=idea,
+                user_requirement=user_requirement
+            )
+            with open("./output/story.txt", "w", encoding='utf-8') as f:
+                f.write(story)
+        
+        # 2. 创建剧本: 分场景创建
+        logger.info("Writing script...")
+        if os.path.exists("./output/script.json"):
+            with open("./output/script.json", "r", encoding='utf-8') as f:
+                script = json.load(f)
+        else:
+            script = story_creator.write_script_on_story(
+                story=story,
+                user_requirement=user_requirement
+            )
+            with open("./output/script.json", "w", encoding='utf-8') as f:
+                json.dump(script, f, ensure_ascii=False, indent=4)
+
+        # 3. 抽取角色
+        logger.info("Extracting characters...")
+        if os.path.exists("./output/characters.json"):
+            with open("./output/characters.json", "r", encoding='utf-8') as f:
+                characters = json.load(f)
+        else:
+            characters = character_extractor.extract_characters(
+                script=script
+            )
+            with open("./output/characters.json", "w", encoding='utf-8') as f:
+                json.dump(characters, f, ensure_ascii=False, indent=4)
+
+        # 4. 设计角色稿
+        logger.info("Designing character portraits...")
+        if os.path.exists("./output/character_portraits.json"):
+            with open("./output/character_portraits.json", "r", encoding='utf-8') as f:
+                character_portraits = json.load(f)
+        else:
+            character_portraits = self._character_portraits_generator(
+                characters=characters,
+                style=style
+            )
+            with open("./output/character_portraits.json", "w", encoding='utf-8') as f:
+                json.dump(character_portraits, f, ensure_ascii=False, indent=4)
+
+        # 5. 为每个场景剧本创建分镜脚本
+        logger.info("Creating storyboard...")
+        if os.path.exists("./output/storyboards.json"):
+            with open("./output/storyboards.json", "r", encoding='utf-8') as f:
+                storyboards = json.load(f)
+        else:
+            storyboards = self._create_storyboard(
+                script=script,
+                characters=str(characters),
+                user_requirement=user_requirement
+            )
+            with open("./output/storyboards.json", "w", encoding='utf-8') as f:
+                json.dump(storyboards, f, ensure_ascii=False, indent=4)
+
+        # 6. 构建相机树
+        logger.info("Building camera tree...")
+        if os.path.exists("./output/storyboards_with_camera_tree.json"):
+            with open("./output/storyboards_with_camera_tree.json", "r", encoding='utf-8') as f:
+                storyboards_with_camera_tree = json.load(f)
+        else:
+            storyboards_with_camera_tree = self._create_camera_tree(
+                storyboards=storyboards
+            )
+            with open("./output/storyboards_with_camera_tree.json", "w", encoding='utf-8') as f:
+                json.dump(storyboards_with_camera_tree, f, ensure_ascii=False, indent=4)
+
+        # 7. 视频帧生成
+        logger.info("Generating video frames...")
+        if os.path.exists("./output/storyboards_with_frames.json"):
+            with open("./output/storyboards_with_frames.json", "r", encoding='utf-8') as f:
+                storyboards_with_frames = json.load(f)
+        else:
+            storyboards_with_frames = self._generate_video_frames_for_scene(
+                storyboards_with_camera_tree=storyboards_with_camera_tree,
+                character_portraits=character_portraits
+            )
+            with open("./output/storyboards_with_frames.json", "w", encoding='utf-8') as f:
+                json.dump(storyboards_with_frames, f, ensure_ascii=False, indent=4)
+
+        # 8. 视频片段生成
+        logger.info("Generating video segments...")
+        if os.path.exists("./output/storyboards_with_segments.json"):
+            with open("./output/storyboards_with_segments.json", "r", encoding='utf-8') as f:
+                storyboards_with_segments = json.load(f)
+        else:
+            storyboards_with_segments = video_generator.generate(
+                video_script_data=storyboards_with_frames
+            )
+            with open("./output/storyboards_with_segments.json", "w", encoding='utf-8') as f:
+                json.dump(storyboards_with_segments[0], f, ensure_ascii=False, indent=4)
+
+        # 9. 拼接视频
+        logger.info("Splicing video...")
+        if os.path.exists("./output/final_video.mp4"):
+            logger.info("Video spliced.")
+        else:
+            concat_videos("./output/storyboards_with_segments.json", "./output/final_video.mp4")
+            logger.info("Video spliced.")
+
+    def _create_storyboard(
+        self,
+        script: dict,
+        characters: str,
+        user_requirement: Optional[str] = None,
+    ):
+        scene_storyboard = []
+        for idx, scene_script in enumerate(script["script"]):
+            logger.info(f"Creating storyboard for scene {idx}...")
+            if os.path.exists(f"./output/storyboard_{idx}.json"):
+                with open(f"./output/storyboard_{idx}.json", "r", encoding='utf-8') as f:
+                    storyboard = json.load(f)
+            else:
+                storyboard = storyboard_creator.create_storyboard(
+                    script=scene_script,
+                    characters=characters,
+                    user_requirement=user_requirement
+                )
+                with open(f"./output/storyboard_{idx}.json", "w", encoding='utf-8') as f:
+                    json.dump(storyboard, f, ensure_ascii=False, indent=4)
+
+            scene_storyboard.append(storyboard)
+            logger.info(f"Storyboard for scene {idx} created.")
+
+        storyboards = {
+            "storyboards": scene_storyboard
+        }
+
+        return storyboards
+
+    def _create_camera_tree(
+        self,
+        storyboards: dict
+    ):
+        for idx, storyboard in enumerate(storyboards["storyboards"]):
+            logger.info(f"Creating camera tree for scene {idx}...")
+            if os.path.exists(f"./output/storyboard_{idx}_with_camera_tree.json"):
+                with open(f"./output/storyboard_{idx}_with_camera_tree.json", "r", encoding='utf-8') as f:
+                    camera_tree = json.load(f)
+            else:
+                camera_tree = camera_tree_creator.create_camera_tree(
+                    shot_descriptions=storyboard["storyboard"]
+                )
+                with open(f"./output/storyboard_{idx}_with_camera_tree.json", "w", encoding='utf-8') as f:
+                    json.dump(camera_tree, f, ensure_ascii=False, indent=4)
+
+            storyboard |= camera_tree
+            logger.info(f"Camera tree for scene {idx} created.")
+
+        return storyboards
+
+    def _character_portraits_generator(
+        self,
+        characters: dict,
+        style: str
+    ):
+        for idx, character in enumerate(characters["characters"]):
+            logger.info(f"Designing portrait for character {idx}...")
+            if os.path.exists(f"./output/portraits_{idx}.json"):
+                logger.info(f"Portrait for character {idx} already exists.")
+                with open(f"./output/portraits_{idx}.json", "r", encoding='utf-8') as f:
+                    portrait_info = json.load(f)
+            else:
+
+                front_image_path = f"./output/front_portrait_{idx}.png"
+                side_image_path = f"./output/side_portrait_{idx}.png"
+                back_image_path = f"./output/back_portrait_{idx}.png"
+
+                front_portrait = character_portraits_generator.generate_front_portrait(
+                    character=character,
+                    style=style
+                )
+                front_portrait.save(front_image_path)
+                
+                side_portrait = character_portraits_generator.generate_side_portrait(
+                    character=character,
+                    front_image_path=[front_image_path]
+                )
+                side_portrait.save(side_image_path)
+                back_portrait = character_portraits_generator.generate_back_portrait(
+                    character=character,
+                    front_image_path=[front_image_path]
+                )
+                back_portrait.save(back_image_path)
+
+
+                portrait_info = {
+                    "front_portrait": front_image_path,
+                    "side_portrait": side_image_path,
+                    "back_portrait": back_image_path
+                }
+
+                with open(f"./output/portraits_{idx}.json", "w", encoding='utf-8') as f:
+                    json.dump(portrait_info, f, ensure_ascii=False, indent=4)
+
+            character |= portrait_info
+            logger.info(f"Portrait for character {idx} designed.")
+
+        return characters
+
+    def _generate_video_frames_for_scene(
+        self,
+        storyboards_with_camera_tree: dict,
+        character_portraits: dict
+    ):
+
+        shot_num = 0
+        for scene_idx, storyboard_with_camera_tree in enumerate(storyboards_with_camera_tree["storyboards"]):
+            logger.info(f"Generating video frames for scene {scene_idx}...")
+
+            storyboard = storyboard_with_camera_tree["storyboard"]
+            camera_tree = storyboard_with_camera_tree["camera_tree"]
+
+            parent_shot_idxs = [0]
+            active_shot_idxs = []
+            for _, item in enumerate(camera_tree):
+                if item["parent_shot_idx"] is not None:
+                    parent_shot_idxs.append(item["parent_shot_idx"])
+                active_shot_idxs.append(item["active_shot_idxs"])
+            
+            process_order = efficient_sort(parent_shot_idxs, active_shot_idxs)
+
+            for cam_idx in process_order:
+                logger.info(f"Processing scene {scene_idx} - camera {cam_idx}...")
+                camera_item = camera_tree[cam_idx]
+                prev_frame_path_and_text_pairs = []
+                for _, shot_idx  in enumerate(camera_item["active_shot_idxs"]):
+                    logger.info(f"Processing scene {scene_idx} - camera {cam_idx} - shot {shot_idx}...")
+                    frame_description = storyboard[shot_idx]["ff_desc"]
+                    vis_char_idxs = storyboard[shot_idx]["ff_vis_char_idxs"]
+
+                    shot_num += 1
+                    image_path_and_text_pairs = []
+                    frame_save_path = f"./output/frame_scene{scene_idx}_camera{cam_idx}_shot{shot_idx}.png"
+
+                    if os.path.exists(frame_save_path):
+                        logger.info(f"Frame for scene {scene_idx} - camera {cam_idx} - shot {shot_idx} already exists.")
+                        continue
+                    else:
+
+                        # 参考可见角色三视图
+                        for vis_char_idx in vis_char_idxs:
+                            logger.info(f"Referencing character {vis_char_idx} portrait...")
+                            image_path_and_text_pairs.append((character_portraits["characters"][vis_char_idx]["front_portrait"], f"{character_portraits['characters'][vis_char_idx]['identifier_in_scene']}的正面肖像"))
+                            image_path_and_text_pairs.append((character_portraits["characters"][vis_char_idx]["side_portrait"], f"{character_portraits['characters'][vis_char_idx]['identifier_in_scene']}的侧面肖像"))
+                            image_path_and_text_pairs.append((character_portraits["characters"][vis_char_idx]["back_portrait"], f"{character_portraits['characters'][vis_char_idx]['identifier_in_scene']}的背面肖像"))
+
+                        # 参考前序帧
+                        image_path_and_text_pairs.extend(prev_frame_path_and_text_pairs)
+
+                        # 参考父帧
+                        if camera_item["parent_shot_idx"] is not None:
+                            image_path_and_text_pairs.append((storyboard[camera_item["parent_shot_idx"]]["ff_path"], storyboard[camera_item["parent_shot_idx"]]["ff_desc"]))
+
+                        # 筛选参考图像,生成生图提示词
+                        info_for_gen_frame = reference_image_selector.select_reference_images_and_generate_prompt(
+                            image_path_and_text_pairs=image_path_and_text_pairs,
+                            frame_description=frame_description
+                        )
+
+                        logger.info(f"目标帧描述:\n{frame_description}")
+                        logger.info(f"可参考帧:\n{image_path_and_text_pairs}")
+                        logger.info(f"实际参考:\n{info_for_gen_frame}")
+
+                        # 生成序列帧
+                        frame_prompt = info_for_gen_frame["text_prompt"]
+                        image_urls = [item[0] for item in info_for_gen_frame["reference_image_path_and_text_pairs"]]
+
+                        logger.info(f"Frame prompt: {frame_prompt}")
+                        logger.info(f"Reference images: {image_urls}")
+
+                        # 开始生成帧
+                        # if len(image_urls) == 0:
+                        #     frame = asyncio.run(image_generator.generate_without_refer(frame_prompt))
+                        #     frame.save_url(frame_save_path)
+                        # else:
+                        #     frame = asyncio.run(image_generator.generate(frame_prompt, image_urls))
+                        #     frame.save_url(frame_save_path)
+
+                        frame = generate_image_from_prompt_and_images(frame_prompt, image_paths=image_urls)
+                        frame.save(frame_save_path)
+                        # 保存前序帧
+                        prev_frame_path_and_text_pairs.append((frame_save_path, frame_description))
+
+                        # storyboard[shot_idx]["ff_url"] = frame.data
+                        storyboard[shot_idx]["ff_path"] = frame_save_path
+
+                    # shot_num += 1
+
+        logger.info(f"Generated {shot_num} video frames.")
+        with open(f"./output/final_storyboards.json", "w", encoding='utf-8') as f:
+            json.dump(storyboards_with_camera_tree, f, ensure_ascii=False, indent=4)
+
+        return storyboards_with_camera_tree
+
+
+if __name__ == "__main__":
+
+    pipeline = Script2VideoPipeline()
+
+    pipeline.video_create_pipeline(
+        idea="身穿时尚服装的美女在街头漫步",
+        user_requirement="剧情要连贯,最多三个场景",
+        style="写实风格"
+    )
+
+    # with open("./output/storyboards_with_camera_tree.json", "r") as f:
+    #     storyboards = json.load(f)
+
+    # full_items = storyboards["storyboards"]
+
+    # for item in full_items:
+    #     camera_tree = item["camera_tree"]
+        
+    #     for camera in camera_tree:
+    #         active_shot_idxs = camera["active_shot_idxs"][0]
+    #         camera["active_shot_idxs"] = active_shot_idxs
+            
+
+    # with open("./output/storyboards_with_camera_treess.json", "w") as f:
+    #     json.dump(storyboards, f, ensure_ascii=False, indent=4)
+
+
+    # 生成角色肖像三视图
+    # with open("./output/characters.json", "r", encoding='utf-8') as f:
+    #     characters = json.load(f)
+
+    # character_portraits = pipeline._character_portraits_generator(
+    #     characters=characters,
+    #     style="cartoon"
+    # )
+
+    # with open("./output/character_portraits.json", "w", encoding='utf-8') as f:
+    #     json.dump(character_portraits, f, ensure_ascii=False, indent=4)
+
+
+
+    # with open("./output/character_portraits.json", "r", encoding='utf-8') as f:
+    #     character_portraits = json.load(f)
+
+    # with open("./output/storyboards_with_camera_tree.json", "r", encoding='utf-8') as f:
+    #     storyboards_with_camera_tree = json.load(f)
+
+    # shot_num = 0
+    # for scene_idx, storyboard_with_camera_tree in enumerate(storyboards_with_camera_tree["storyboards"]):
+    #     for shot_idx, shot in enumerate(storyboard_with_camera_tree["storyboard"]):
+    #         ff_path = f"./output/frame_scene{scene_idx}_camera{shot['cam_idx']}_shot{shot_idx}.png"
+    #         if os.path.exists(ff_path):
+    #             shot["ff_path"] = ff_path
+    #             shot_num += 1
+
+    # logger.info(f"Total shot number: {shot_num}")
+
+    
+    # # 生成视频帧
+    # result = pipeline._generate_video_frames_for_scene(
+    #     storyboards_with_camera_tree=storyboards_with_camera_tree,
+    #     character_portraits=character_portraits
+    # )
+
+    # with open("./output/storyboards_with_frames.json", "w", encoding='utf-8') as f:
+    #     json.dump(storyboards_with_camera_tree, f, ensure_ascii=False, indent=4)
+
+    # # 将指定目录下的所有frame_scene*.png文件重命名为new_frame_scene*.png
+    # for file in os.listdir("./output"):
+    #     if file.startswith("frame_scene") and file.endswith(".png"):
+    #         new_file = file.replace("frame_scene", "new_frame_scene")
+    #         os.rename(os.path.join("./output", file), os.path.join("./output", new_file))
+
+    # 生成视频片段
+    # with open("./output/storyboards_with_frames.json", "r", encoding='utf-8') as f:
+    #     final_storyboards = json.load(f)
+
+    # storyboards_with_segments = video_generator.generate(
+    #     video_script_data=final_storyboards
+    # )
+    # with open("./output/storyboards_with_segments.json", "w", encoding='utf-8') as f:
+    #     json.dump(storyboards_with_segments, f, ensure_ascii=False, indent=4)
+
+
+    # concat_videos("./output/storyboards_with_segments.json")
+    

+ 0 - 0
tools/__init__.py


+ 76 - 0
tools/banana_pro.py

@@ -0,0 +1,76 @@
+from google import genai
+from google.genai import types
+from PIL import Image
+import os
+from dotenv import load_dotenv
+from typing import List, Optional
+
+load_dotenv()
+
+client = genai.Client(api_key=os.getenv("BANANA_PRO_KEY"))
+
+print(f"BANANA_PRO_KEY: {os.getenv('BANANA_PRO_KEY')}")
+
+def generate_image_from_prompt_and_images(
+    prompt: str,
+    image_paths: List[str],
+    aspect_ratio: str = "16:9",
+    resolution: str = "2K"
+) -> Optional[Image.Image]:
+    """
+    使用 Gemini API 生成图像
+    
+    参数:
+        prompt: 文本提示词
+        image_paths: 参考图像路径列表
+        aspect_ratio: 图像宽高比,可选值: "1:1","2:3","3:2","3:4","4:3","4:5","5:4","9:16","16:9","21:9"
+        resolution: 图像分辨率,可选值: "1K", "2K", "4K"
+    
+    返回:
+        生成的图像对象 (PIL Image),如果生成失败则返回 None
+    """
+    # 构建内容列表:包含提示词和所有参考图像
+    contents = [prompt]
+    for image_path in image_paths:
+        contents.append(Image.open(image_path))
+    
+    # 调用 API 生成内容
+    response = client.models.generate_content(
+        model="gemini-3-pro-image-preview",
+        contents=contents,
+        config=types.GenerateContentConfig(
+            response_modalities=['TEXT', 'IMAGE'],
+            image_config=types.ImageConfig(
+                aspect_ratio=aspect_ratio,
+                image_size=resolution
+            ),
+        )
+    )
+    
+    # 处理响应,提取图像
+    for part in response.parts:
+        if part.text is not None:
+            print(part.text)
+        elif image := part.as_image():
+            return image
+    
+    return None
+
+
+if __name__ == "__main__":
+    # 测试代码
+    prompt = "男人在月球上行走"
+    image_paths = ["./test_output/output/front_portrait_0.png"]
+    aspect_ratio = "16:9"
+    resolution = "1K"
+    
+    generated_image = generate_image_from_prompt_and_images(
+        prompt=prompt,
+        image_paths=image_paths,
+        aspect_ratio=aspect_ratio,
+        resolution=resolution
+    )
+    
+    if generated_image:
+        generated_image.save("xxxxxxx.png")
+        print("图像已保存到 xxxxxxx.png")

+ 126 - 0
tools/gemini3.py

@@ -0,0 +1,126 @@
+import requests
+import base64
+from PIL import Image
+import json
+import io
+import os
+from dotenv import load_dotenv
+from interfaces.image_output import ImageOutput
+from utils.logger_config import setup_logger
+
+logger = setup_logger(__name__)
+
+load_dotenv()
+API_KEY = os.getenv("GEMINI_API_KEY")
+
+def image_to_base64(image_path):
+    """将图片转换为base64字符串"""
+    with Image.open(image_path) as img:
+        # 转换为RGB模式(如果需要)
+        if img.mode != 'RGB':
+            img = img.convert('RGB')
+        
+        # 将图片转换为base64
+        buffer = io.BytesIO()
+        img.save(buffer, format='PNG')
+        img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
+        return img_base64
+
+
+def generate_image_from_prompt_and_images(
+    prompt="An office group photo of these people, they are making funny faces.",
+    image_paths=['output_front.png'],
+    aspect_ratio="5:4",
+    resolution="1K",
+    api_key=API_KEY,
+    url="https://api.openaius.com/v1beta/models/gemini-3-pro-image-preview:generateContent"
+):
+    """
+    使用Gemini API根据提示词和参考图片生成新图片
+    
+    Args:
+        prompt (str): 提示词
+        image_paths (list): 参考图片路径列表
+        aspect_ratio (str): 图片宽高比
+        resolution (str): 图片分辨率
+        api_key (str): API密钥
+        url (str): API端点URL
+        
+    Returns:
+        dict: API响应结果
+    """
+    # 读取并转换所有图片
+    images_base64 = []
+    for img_path in image_paths:
+        try:
+            img_b64 = image_to_base64(img_path)
+            images_base64.append(img_b64)
+        except Exception as e:
+            logger.error(f"Error loading image {img_path}: {e}")
+            return None
+
+    # 构建请求体
+    payload = {
+        "contents": [
+            {
+                "role": "user",
+                "parts": [
+                    {"text": prompt},
+                    *[{"inline_data": {"mime_type": "image/png", "data": img_b64}} for img_b64 in images_base64]
+                ]
+            }
+        ],
+        "generation_config": {
+            "response_modalities": ["TEXT", "IMAGE"],
+            "image_config": {
+                "aspect_ratio": aspect_ratio,
+                "image_size": resolution
+            }
+        }
+    }
+
+    # 设置请求头
+    headers = {
+        "Authorization": f"Bearer {api_key}",
+        "Content-Type": "application/json"
+    }
+
+    # 发送请求
+    try:
+        response = requests.post(url, headers=headers, json=payload, timeout=60)
+        response.raise_for_status()  # 检查HTTP错误
+        
+        result = response.json()
+
+        # 处理响应
+        if "candidates" in result and len(result["candidates"]) > 0:
+            candidate = result["candidates"][0]
+            if "content" in candidate and "parts" in candidate["content"]:
+                for part in candidate["content"]["parts"]:
+                    if "text" in part:
+                        logger.info(part["text"])
+                    elif "inlineData" in part and part["inlineData"]["mimeType"] == "image/jpeg":
+                        img_data = base64.b64decode(part["inlineData"]["data"])
+        
+        return ImageOutput(fmt="b64", ext="png", data=img_data)
+        
+    except requests.exceptions.RequestException as e:
+        logger.error(f"Request failed: {e}")
+        if hasattr(e, 'response') and e.response is not None:
+            logger.error(f"Response status: {e.response.status_code}")
+            logger.error(f"Response body: {e.response.text}")
+    except json.JSONDecodeError as e:
+        logger.error(f"Failed to parse JSON response: {e}")
+        logger.error(f"Raw response: {response.text}")
+    except Exception as e:
+        logger.error(f"Unexpected error: {e}")
+    
+    return None
+
+
+# 如果直接运行此脚本,则执行函数
+if __name__ == "__main__":
+    prompt = "生成一张美女图片,表情可爱"
+    result = generate_image_from_prompt_and_images(prompt, image_paths=[])
+
+    result.save_img("output_banana_pro.png")

+ 372 - 0
tools/image_generator.py

@@ -0,0 +1,372 @@
+import os
+import time
+import requests
+import json
+import threading
+import asyncio
+import aiohttp
+from typing import Optional, Dict, Callable
+from dotenv import load_dotenv
+from interfaces.image_output import ImageOutput
+from utils.tools import encode_image, download_image, download_video
+from utils.upload import upload_file_to_tos
+from utils.logger_config import setup_logger
+
+load_dotenv()
+
+logger = setup_logger(__name__)
+
+class ArkImageGenerator:
+    """Ark 图片生成 API 封装类"""
+    
+    def __init__(
+        self,
+        auth_token: str = None,
+        model: str = "doubao-seedream-4-0-250828",
+        sequential_generation: str = "disabled",
+        response_format: str = "url",
+        stream: bool = False,
+        watermark: bool = True,
+        timeout: int = 120
+    ):
+        """
+        初始化图片生成器
+        
+        参数:
+            auth_token: 认证令牌(Bearer Token)
+            model: 模型名称(固定配置)
+            sequential_generation: 序列生成开关(固定配置)
+            response_format: 响应格式(固定配置)
+            stream: 流式响应开关(固定配置)
+            watermark: 水印开关(固定配置)
+            timeout: 请求超时时间(秒)
+        """
+        self.api_url = "https://ark.cn-beijing.volces.com/api/v3/images/generations"
+
+        if not auth_token:
+            auth_token = os.getenv("ARK_API_KEY")
+
+        self.headers = {
+            "Content-Type": "application/json",
+            "Authorization": f"Bearer {auth_token}"
+        }
+        # 固定配置参数
+        self.config = {
+            "model": model,
+            "sequential_image_generation": sequential_generation,
+            "response_format": response_format,
+            "stream": stream,
+            "watermark": watermark
+        }
+        self.timeout = timeout
+
+    async def generate_without_refer(self, prompt: str, size: str = "1440x2560") -> Optional[Dict]:
+        if not prompt:
+            logger.info("错误:prompt不能为空")
+            return None
+
+        payload = {
+            **self.config,
+            "prompt": prompt,
+            "size": size
+        }
+        try:
+            # 使用 aiohttp 进行异步请求
+            async with aiohttp.ClientSession() as session:
+                async with session.post(
+                    url=self.api_url,
+                    headers=self.headers,
+                    json=payload,
+                    timeout=self.timeout
+                ) as response:
+                    response.raise_for_status()
+                    result_data = await response.json()
+                    result_image = result_data["data"][0]["url"]
+                    return ImageOutput(fmt="url", ext="png", data=result_image)
+        
+        except Exception as e:
+            logger.info(f"错误:生成图片时发生异常:{e}")
+            return None
+
+    async def generate(self, prompt: str, image_url: list[str], size: str = "1440x2560") -> Optional[Dict]:
+        # 验证必填参数
+        if not prompt or not image_url:
+            logger.info("错误:prompt和image_url不能为空")
+            return None
+
+        # 如果image_url为图片路径,则编码为base64格式
+        reference_image = [encode_image(image_url[i]) if "http" not in image_url[i] else image_url[i] for i in range(len(image_url))]
+        
+        # 构建请求体(合并固定配置和动态参数)
+        payload = {
+            **self.config,
+            "prompt": prompt,
+            "image": reference_image,
+            "size": size
+        }
+        try:
+            # 使用 aiohttp 进行异步请求
+            async with aiohttp.ClientSession() as session:
+                async with session.post(
+                    url=self.api_url,
+                    headers=self.headers,
+                    json=payload,
+                    timeout=self.timeout
+                ) as response:
+                    response.raise_for_status()
+                    result_data = await response.json()
+                    result_image = result_data["data"][0]["url"]
+                    return ImageOutput(fmt="url", ext="png", data=result_image)
+        
+        except Exception as e:
+            logger.info(f"错误:生成图片时发生异常:{e}")
+            return None
+
+class ArkVideoGenerator:
+    """Ark 图生视频 API 封装类,支持通过参考图和文本描述生成视频"""
+    
+    def __init__(
+        self,
+        auth_token: str = None,
+        model: str = "doubao-seedance-1-0-pro-250528",
+        timeout: int = 60,
+        poll_interval: int = 5,  # 轮询间隔(秒)
+        max_poll_time: int = 500  # 最大轮询总时间(秒)
+    ):
+        # 固定 API 端点
+        self.api_url = "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks"
+
+        if not auth_token:
+            auth_token = os.getenv("ARK_API_KEY")
+        # 固定请求头
+        self.headers = {
+            "Content-Type": "application/json",
+            "Authorization": f"Bearer {auth_token}"
+        }
+        # 固定配置参数(模型、超时)
+        self.model = model
+        self.timeout = timeout
+        self.poll_interval = poll_interval
+        self.max_poll_time = max_poll_time
+
+    def create_video_task(
+        self,
+        prompt: str,
+        image_url: str,
+        gen_params: str = ""
+    ) -> Optional[Dict]:
+        # 1. 验证动态参数合法性
+        if not prompt.strip():
+            logger.info("错误:文本描述(text_prompt)不能为空")
+            return None
+        if not image_url.strip():
+            logger.info("错误:参考图 URL(reference_image_url)不能为空")
+            return None
+
+        refernece_image = encode_image(image_url) if "http" not in image_url else image_url
+        
+        # 2. 构建请求体(按 API 要求格式组装 content 列表)
+        payload = {
+            "model": self.model,
+            "content": [
+                {
+                    "type": "text",
+                    "text": prompt + gen_params
+                },
+                {
+                    "type": "image_url",
+                    "image_url": {
+                        "url": refernece_image
+                    }
+                }
+            ]
+        }
+        
+        # 3. 发送 POST 请求并处理响应
+        try:
+            response = requests.post(
+                url=self.api_url,
+                headers=self.headers,
+                data=json.dumps(payload, ensure_ascii=False),
+                timeout=self.timeout
+            )
+            response.raise_for_status()
+            return response.json()
+        
+        except Exception as e:
+            logger.info(f"创建任务失败:{str(e)}")
+            return None
+
+    def query_video_task(self, task_id: str) -> Optional[Dict]:
+        """
+        新增:查询图生视频任务结果
+        
+        参数:
+            task_id: 视频任务 ID(从 create_video_task 响应中获取)
+        
+        返回:
+            任务结果详情(含视频状态、视频 URL 等)或 None
+        """
+        # 1. 验证任务 ID
+        if not task_id.strip():
+            logger.info("错误:任务 ID(task_id)不能为空")
+            return None
+        
+        # 2. 构建查询 URL(拼接 task_id)
+        query_url = f"{self.api_url}/{task_id}"
+        
+        # 3. 发送 GET 请求查询结果
+        try:
+            response = requests.get(
+                url=query_url,
+                headers=self.headers,
+                timeout=self.timeout
+            )
+            # 触发 HTTP 错误(如 404 任务不存在、401 令牌无效)
+            response.raise_for_status()
+            return response.json()
+
+        except Exception as e:
+            logger.info(f"查询任务失败:{str(e)}")
+            return None
+
+    def _background_poll(
+        self,
+        task_id: str,
+        filename: str,
+        callback: Callable[[str, str, Optional[Dict], Optional[str]], None]
+    ):
+        """
+        后台轮询任务状态的线程函数
+        :param task_id: 任务ID
+        :param callback: 回调函数,参数为 (task_id, 成功结果, 错误信息)
+        """
+        start_time = time.time()
+        while True:
+            elapsed = time.time() - start_time
+            if elapsed > self.max_poll_time:
+                callback(task_id, filename, None, f"任务超时(超过 {self.max_poll_time} 秒)")
+
+            # 查询任务状态
+            result = self.query_video_task(task_id)
+            if not result:
+                time.sleep(self.poll_interval)
+                continue
+            
+            # 解析状态
+            status = result.get("status", "").lower()
+            if status == "succeeded":
+                callback(task_id, filename, result, None)
+                return 
+            elif status == "failed":
+                error_msg = result.get("error", {}).get("message", "未知错误")
+                callback(task_id, filename, None, error_msg)
+                return
+            elif status in ["pending", "processing"]:
+                logger.info(f"任务 {task_id} 处理中({int(elapsed)}秒),状态:{status}")
+                time.sleep(self.poll_interval)
+            else:
+                logger.info(f"任务 {task_id} 未知状态:{status},继续等待...")
+                time.sleep(self.poll_interval)
+
+    def create_video_task_async(
+        self,
+        prompt: str,
+        image_url: str,
+        gen_params: str,
+        filename: str,
+        callback: Callable[[str, str, Optional[Dict], Optional[str]], None]
+    ) -> Optional[str]:
+        # 1. 提交任务
+        task_response = self.create_video_task(prompt, image_url, gen_params)
+        if not task_response or "id" not in task_response:
+            logger.info("任务提交失败,无法启动后台轮询")
+            return None
+
+        task_id = task_response["id"]
+        logger.info(f"任务提交成功,task_id: {task_id},启动后台轮询...")
+
+        # 2. 启动后台线程轮询结果
+        poll_thread = threading.Thread(
+            target=self._background_poll,
+            args=(task_id, filename, callback),
+            daemon=True  # 守护线程:主程序退出时自动结束
+        )
+        poll_thread.start()
+
+        return task_id
+        
+# 1. 定义回调函数:任务完成/失败时会被调用
+def handle_video_result(task_id: str, filename, result: Optional[Dict], error: Optional[str]) -> None:
+    if error:
+        logger.info(f"\n任务 {task_id} 处理失败:{error}")
+    else:
+        video_url = result.get("content", {}).get("video_url")
+        output_path = "./output/" + filename
+        download_video(video_url, output_path) 
+        logger.info(f"生成视频已下载:{output_path}")
+                
+
+# API配置
+API_URL = os.getenv("AUDIO_GEN_API")
+
+def audio_generator(text, spk_audio="./data/audio/voice_07.wav", emo_audio="./data/audio/emo_sad.wav"):
+    """调用TTS API生成语音"""
+    payload = {
+        "text": text,
+        "spk_audio_prompt": spk_audio,
+        "emo_audio_prompt": emo_audio
+    }
+    
+    response = requests.post(API_URL, json=payload)
+    
+    if response.status_code == 200:
+        result = response.json()
+        if result["status"] == "success":
+            print(f"语音生成成功: {result['audio_file']}")
+            return result["audio_file"]
+    else:
+        print(f"请求失败: {response.text}")
+        return None
+
+
+image_generator = ArkImageGenerator()
+video_generator = ArkVideoGenerator()
+    
+if __name__ == "__main__":
+    # 1. 初始化生成器(配置固定参数)
+    image_generator = ArkImageGenerator()
+    video_generator = ArkVideoGenerator()
+    
+    # 2. 调用生成方法(仅传入动态参数)
+    result = image_generator.generate(
+        prompt="狗狗在草地上追逐蒲公英",
+        image_url="https://ark-project.tos-cn-beijing.volces.com/doc_image/seedream4_imageToimage.png",
+        filename="1.jpg"
+    )
+    
+    logger.info(f"result:{result}")
+
+    # print(video_generator.query_video_task("cgt-20251022103303-9hgrr"))
+
+    # cgt-20251022101137-852fw cgt-20251022102536-kt8pj cgt-20251022103303-9hgrr
+
+    # task_id = video_generator.create_video_task_async(
+    #     prompt="狗狗不停地在草地上跳跃",
+    #     image_url="https://ark-project.tos-cn-beijing.volces.com/doc_image/seedream4_imageToimage.png",
+    #     gen_params="",
+    #     filename="1.mp4",
+    #     callback=handle_video_result
+    # )
+
+    # if task_id:
+    #     print("\n主流程:任务已提交,开始执行其他操作...")
+    #     for i in range(10):
+    #         print(f"主流程:正在执行第 {i+1} 步操作...")
+    #         time.sleep(1)  # 模拟主流程耗时操作
+    #     print("主流程:所有操作执行完毕,等待后台任务结果(若未完成)...")
+        
+    #     # 防止主程序提前退出(实际生产环境可能有其他阻塞逻辑)
+    #     # 这里仅为演示:等待所有后台线程完成
+    #     while threading.active_count() > 1:
+    #         time.sleep(1)

+ 685 - 0
tools/text_generator.py

@@ -0,0 +1,685 @@
+import os
+import base64
+import io
+import asyncio
+import aiohttp
+import time
+import functools
+from concurrent.futures import ThreadPoolExecutor
+from PIL import Image
+from typing import Optional, Dict, Any, Literal, Union, List, Callable
+from volcenginesdkarkruntime import Ark
+from utils.logger_config import setup_logger
+from utils.config_manager import ConfigManager
+from dotenv import load_dotenv
+
+# 加载.env文件
+load_dotenv()
+
+logger = setup_logger(__name__)
+
+def async_performance_monitor(func: Callable):
+    """异步方法性能监控装饰器"""
+    @functools.wraps(func)
+    async def wrapper(*args, **kwargs):
+        start_time = time.time()
+        try:
+            result = await func(*args, **kwargs)
+            end_time = time.time()
+            execution_time = end_time - start_time
+            logger.info(f"{func.__name__} completed in {execution_time:.2f} seconds")
+            return result
+        except Exception as e:
+            end_time = time.time()
+            execution_time = end_time - start_time
+            logger.error(f"{func.__name__} failed after {execution_time:.2f} seconds: {str(e)}")
+            raise
+    return wrapper
+
+def sync_performance_monitor(func: Callable):
+    """同步方法性能监控装饰器"""
+    @functools.wraps(func)
+    def wrapper(*args, **kwargs):
+        start_time = time.time()
+        try:
+            result = func(*args, **kwargs)
+            end_time = time.time()
+            execution_time = end_time - start_time
+            logger.info(f"{func.__name__} completed in {execution_time:.2f} seconds")
+            return result
+        except Exception as e:
+            end_time = time.time()
+            execution_time = end_time - start_time
+            logger.error(f"{func.__name__} failed after {execution_time:.2f} seconds: {str(e)}")
+            raise
+    return wrapper
+
+class MediaCaptioner:
+    """媒体描述生成器,使用火山引擎API进行视频、图像和文本内容理解"""
+    
+    def __init__(self, api_key: Optional[str] = None, 
+                 base_url: str = "https://ark.cn-beijing.volces.com/api/v3",
+                 model: str = "doubao-seed-1-6-250615",
+                 config_path: Optional[str] = None):
+        """
+        初始化媒体描述生成器
+        
+        Args:
+            api_key: 火山引擎API密钥,如果为None则从环境变量获取
+            base_url: API基础URL
+            model: 使用的模型ID
+            config_path: 提示词配置文件路径
+        """
+        try:
+            self.api_key = api_key or os.getenv("VOLC_API_KEY")
+            if not self.api_key:
+                raise ValueError("API key must be provided either through constructor or environment variable VOLC_API_KEY")
+            
+            self.client = Ark(
+                api_key=self.api_key,
+                base_url=base_url
+            )
+            self.base_url = base_url
+            self.model = model
+            self.config_manager = ConfigManager(config_path)
+            logger.info(f"Initialized MediaCaptioner with model: {model}")
+            
+        except Exception as e:
+            logger.error(f"Failed to initialize MediaCaptioner: {str(e)}")
+            raise
+
+    @sync_performance_monitor
+    def _encode_video(self, video_path: str) -> str:
+        """
+        将视频文件转换为base64编码
+        
+        Args:
+            video_path: 视频文件路径
+            
+        Returns:
+            str: base64编码的视频数据
+            
+        Raises:
+            FileNotFoundError: 视频文件不存在
+            IOError: 读取文件失败
+        """
+        if not os.path.exists(video_path):
+            raise FileNotFoundError(f"Video file not found: {video_path}")
+            
+        with open(video_path, "rb") as f:
+            return base64.b64encode(f.read()).decode("utf-8")
+
+    @sync_performance_monitor
+    def _encode_image(self, image_path: str) -> str:
+        """
+        将图片文件转换为base64编码
+        
+        Args:
+            image_path: 图片文件路径
+            
+        Returns:
+            str: base64编码的图片数据
+            
+        Raises:
+            FileNotFoundError: 图片文件不存在
+            IOError: 读取或处理图片失败
+        """
+        if not os.path.exists(image_path):
+            raise FileNotFoundError(f"Image file not found: {image_path}")
+            
+        with Image.open(image_path) as img:
+            buffered = io.BytesIO()
+            img.save(buffered, format="JPEG")
+            return base64.b64encode(buffered.getvalue()).decode("utf-8")
+
+    def generate_video_caption(self, 
+                             video_path: str, 
+                             prompt_type: str = "caption",
+                             scenario: Optional[str] = None,
+                             fps: int = 2,
+                             context_info: Optional[str] = None) -> Optional[str]:
+        """
+        生成视频描述的同步包装器
+        
+        Args:
+            video_path: 视频文件路径
+            prompt_type: 提示词类型
+            scenario: 场景类型
+            fps: 视频采样帧率
+            
+        Returns:
+            str: 视频描述,如果处理失败则返回None
+        """
+        try:
+            loop = asyncio.get_event_loop()
+        except RuntimeError:
+            loop = asyncio.new_event_loop()
+            asyncio.set_event_loop(loop)
+        return loop.run_until_complete(
+            self._process_video_async(
+                file_path=video_path,
+                prompt_type=prompt_type,
+                scenario=scenario,
+                fps=fps,
+                context_info=context_info
+            )
+        )
+
+    async def generate_video_caption_async(self, 
+                                         video_path: str, 
+                                         prompt_type: str = "caption",
+                                         scenario: Optional[str] = None,
+                                         fps: int = 2,
+                                         context_info: Optional[str] = None) -> Optional[str]:
+        """
+        异步生成视频描述
+        
+        Args:
+            video_path: 视频文件路径
+            prompt_type: 提示词类型
+            scenario: 场景类型
+            fps: 视频采样帧率
+            
+        Returns:
+            str: 视频描述,如果处理失败则返回None
+        """
+        return await self._process_video_async(
+            file_path=video_path,
+            prompt_type=prompt_type,
+            scenario=scenario,
+            fps=fps,
+            context_info=context_info
+        )
+
+    def generate_image_caption(self, 
+                             image_path: str, 
+                             prompt_type: str = "caption",
+                             scenario: Optional[str] = None,
+                             context_info: Optional[str] = None) -> Optional[str]:
+        """
+        生成图片描述的同步包装器
+        
+        Args:
+            image_path: 图片文件路径
+            prompt_type: 提示词类型
+            scenario: 场景类型
+            
+        Returns:
+            str: 图片描述,如果处理失败则返回None
+        """
+        try:
+            loop = asyncio.get_event_loop()
+        except RuntimeError:
+            loop = asyncio.new_event_loop()
+            asyncio.set_event_loop(loop)
+        return loop.run_until_complete(
+            self._process_image_async(
+                file_path=image_path,
+                prompt_type=prompt_type,
+                scenario=scenario,
+                context_info=context_info
+            )
+        )
+
+    async def generate_image_caption_async(self, 
+                                         image_path: str, 
+                                         prompt_type: str = "caption",
+                                         scenario: Optional[str] = None,
+                                         context_info: Optional[str] = None) -> Optional[str]:
+        """
+        异步生成图片描述
+        
+        Args:
+            image_path: 图片文件路径
+            prompt_type: 提示词类型
+            scenario: 场景类型
+            
+        Returns:
+            str: 图片描述,如果处理失败则返回None
+        """
+        return await self._process_image_async(
+            file_path=image_path,
+            prompt_type=prompt_type,
+            scenario=scenario,
+            context_info=context_info
+        )
+
+    def generate_text_understanding(self,
+                                  user_prompt: str,
+                                  system_prompt: str,
+                                  max_length: Optional[int] = None,
+                                  context_info: Optional[str] = None) -> Optional[str]:
+        """
+        生成文本理解结果的同步包装器
+        
+        Args:
+            user_prompt: 需要理解的文本内容
+            system_prompt: 提示词类型
+            scenario: 场景类型
+            max_length: 最大输出长度
+            
+        Returns:
+            str: 文本理解结果,如果处理失败则返回None
+        """
+        try:
+            loop = asyncio.get_event_loop()
+        except RuntimeError:
+            loop = asyncio.new_event_loop()
+            asyncio.set_event_loop(loop)
+        return loop.run_until_complete(
+            self._process_text_async(
+                user_prompt=user_prompt,
+                system_prompt=system_prompt,
+                max_length=max_length,
+                context_info=context_info
+            )
+        )
+
+    async def generate_text_understanding_async(self,
+                                              text: str,
+                                              prompt_type: str = "summary",
+                                              scenario: Optional[str] = None,
+                                              max_length: Optional[int] = None,
+                                              context_info: Optional[str] = None) -> Optional[str]:
+        """
+        异步生成文本理解结果
+        
+        Args:
+            text: 需要理解的文本内容
+            prompt_type: 提示词类型
+            scenario: 场景类型
+            max_length: 最大输出长度
+            
+        Returns:
+            str: 文本理解结果,如果处理失败则返回None
+        """
+        return await self._process_text_async(
+            text=text,
+            prompt_type=prompt_type,
+            scenario=scenario,
+            max_length=max_length,
+            context_info=context_info
+        )
+
+    def generate_multi_aspect_understanding(self,
+                                          text: str,
+                                          prompt_types: List[str],
+                                          scenario: Optional[str] = None) -> Dict[str, Optional[str]]:
+        """
+        从多个角度生成文本理解结果的同步包装器
+        
+        Args:
+            text: 需要理解的文本内容
+            prompt_types: 提示词类型列表
+            scenario: 场景类型
+            
+        Returns:
+            Dict[str, Optional[str]]: 提示词类型到理解结果的映射
+        """
+        try:
+            loop = asyncio.get_event_loop()
+        except RuntimeError:
+            loop = asyncio.new_event_loop()
+            asyncio.set_event_loop(loop)
+        return loop.run_until_complete(
+            self.generate_multi_aspect_understanding_async(
+                text=text,
+                prompt_types=prompt_types,
+                scenario=scenario
+            )
+        )
+
+    async def generate_multi_aspect_understanding_async(self,
+                                                      text: str,
+                                                      prompt_types: List[str],
+                                                      scenario: Optional[str] = None,
+                                                      context_info: Optional[str] = None) -> Dict[str, Optional[str]]:
+        """
+        异步从多个角度生成文本理解结果
+        
+        Args:
+            text: 需要理解的文本内容
+            prompt_types: 提示词类型列表
+            scenario: 场景类型
+            
+        Returns:
+            Dict[str, Optional[str]]: 提示词类型到理解结果的映射
+        """
+        tasks = [
+            self._process_text_async(
+                text=text,
+                prompt_type=prompt_type,
+                scenario=scenario,
+                context_info=context_info
+            )
+            for prompt_type in prompt_types
+        ]
+        
+        results = await asyncio.gather(*tasks)
+        return dict(zip(prompt_types, results))
+
+    async def _make_api_request(self, 
+                              endpoint: str,
+                              payload: Dict[str, Any],
+                              timeout: int = 180) -> Dict[str, Any]:
+        """
+        发送API请求的通用方法
+        
+        Args:
+            endpoint: API端点
+            payload: 请求负载
+            timeout: 超时时间(秒)
+            
+        Returns:
+            Dict[str, Any]: API响应
+            
+        Raises:
+            aiohttp.ClientError: API请求失败
+            asyncio.TimeoutError: 请求超时
+        """
+        try:
+            async with aiohttp.ClientSession() as session:
+                async with session.post(
+                    f"{self.base_url}/{endpoint}",
+                    json=payload,
+                    headers={"Authorization": f"Bearer {self.api_key}"},
+                    timeout=timeout
+                ) as response:
+                    if response.status != 200:
+                        error_text = await response.text()
+                        raise aiohttp.ClientError(f"API request failed with status {response.status}: {error_text}")
+                    return await response.json()
+        except asyncio.TimeoutError:
+            logger.error(f"API request timed out after {timeout} seconds")
+            raise
+        except Exception as e:
+            logger.error(f"API request failed: {str(e)}")
+            raise
+
+    @async_performance_monitor
+    async def _process_video_async(self, file_path: str, prompt_type: str,
+                                 scenario: Optional[str] = None, fps: int = 2, context_info: Optional[str] = None) -> Optional[str]:
+        """异步处理视频文件"""
+        try:
+            # 在线程池中执行文件IO操作
+            loop = asyncio.get_event_loop()
+            with ThreadPoolExecutor() as pool:
+                base64_video = await loop.run_in_executor(
+                    pool, self._encode_video, file_path
+                )
+            
+            prompt = self.config_manager.get_prompt("video", prompt_type, scenario)
+
+            # 构建API请求
+            payload = {
+                "model": self.model,
+                "messages": [{
+                    "role": "system",
+                    "content": [
+                        {
+                            "type": "video_url",
+                            "video_url": {
+                                "url": f"data:video/mp4;base64,{base64_video}",
+                                "fps": fps
+                            }
+                        },
+                        {
+                            "type": "text",
+                            "text": prompt
+                        }
+                    ]
+                },
+                {
+                    "role": "user",
+                    "content": f"上下文信息:{context_info}"
+                }
+                ]
+            }
+            
+            # 发送API请求
+            response = await self._make_api_request("chat/completions", payload)
+            return response["choices"][0]["message"]["content"]
+                
+        except Exception as e:
+            logger.error(f"Failed to process video async: {str(e)}")
+            return None
+
+    @async_performance_monitor
+    async def _process_image_async(self, file_path: str, prompt_type: str,
+                                 scenario: Optional[str] = None, context_info: Optional[str] = None) -> Optional[str]:
+        """异步处理图片文件"""
+        try:
+            # 在线程池中执行文件IO操作
+            loop = asyncio.get_event_loop()
+            with ThreadPoolExecutor() as pool:
+                base64_image = await loop.run_in_executor(
+                    pool, self._encode_image, file_path
+                )
+            
+            prompt = self.config_manager.get_prompt("image", prompt_type, scenario)
+            
+            # 构建API请求
+            payload = {
+                "model": self.model,
+                "messages": [{
+                    "role": "system",
+                    "content": [
+                        {
+                            "type": "image_url",
+                            "image_url": {
+                                "url": f"data:image/jpeg;base64,{base64_image}"
+                            }
+                        },
+                        {
+                            "type": "text",
+                            "text": prompt
+                        }
+                    ]
+                },
+                {
+                    "role": "user",
+                    "content": f"上下文信息:{context_info}"
+                }
+                ]
+            }
+            
+            # 发送API请求
+            response = await self._make_api_request("chat/completions", payload)
+            return response["choices"][0]["message"]["content"]
+                
+        except Exception as e:
+            logger.error(f"Failed to process image async: {str(e)}")
+            return None
+
+    @async_performance_monitor
+    async def _process_text_async(self, user_prompt: str, system_prompt: str,
+                                max_length: Optional[int] = None,
+                                context_info: Optional[str] = None) -> Optional[str]:
+        """异步处理文本内容"""
+        # try:
+        if not user_prompt.strip():
+            logger.error("Empty text provided")
+            return None
+        
+        # 构建API请求
+        payload = {
+            "model": self.model,
+            "messages": [
+                {
+                    "role": "system",
+                    "content": system_prompt
+                },
+                {
+                    "role": "user",
+                    "content": user_prompt
+                },
+                {
+                    "role": "user",
+                    "content": f"上下文信息:{context_info}"
+                }
+            ],
+            "max_tokens": max_length if max_length else None
+        }
+        
+        # 发送API请求
+        response = await self._make_api_request("chat/completions", payload)
+        return response["choices"][0]["message"]["content"]
+                
+        # except Exception as e:
+        #     logger.error(f"Failed to process text async: {str(e)}")
+        #     return None
+
+    async def generate_batch_captions_async(self, 
+                                          files: Dict[str, Dict[str, Union[str, int]]],
+                                          scenario: Optional[str] = None,
+                                          max_concurrent: int = 5) -> Dict[str, Optional[str]]:
+        """
+        异步批量生成媒体描述
+        
+        Args:
+            files: 文件配置字典
+            scenario: 场景类型
+            max_concurrent: 最大并发数
+            
+        Returns:
+            Dict[str, Optional[str]]: 文件路径或标识符到描述的映射
+        """
+        results = {}
+        # 创建信号量控制并发
+        semaphore = asyncio.Semaphore(max_concurrent)
+        
+        async def process_single_file(file_path: str, config: Dict[str, Any]) -> tuple[str, Optional[str]]:
+            """处理单个文件的异步函数"""
+            async with semaphore:  # 使用信号量控制并发
+                try:
+                    media_type = config["type"]
+                    prompt_type = config.get("prompt_type", "caption" if media_type != "text" else "summary")
+                    
+                    if media_type == "video":
+                        fps = config.get("fps", 2)
+                        result = await self._process_video_async(
+                            file_path=file_path,
+                            prompt_type=prompt_type,
+                            scenario=scenario,
+                            fps=fps,
+                            context_info=config.get("context_info")
+                        )
+                    elif media_type == "image":
+                        result = await self._process_image_async(
+                            file_path=file_path,
+                            prompt_type=prompt_type,
+                            scenario=scenario,
+                            context_info=config.get("context_info")
+                        )
+                    elif media_type == "text":
+                        if "content" not in config:
+                            logger.error(f"Text content not provided for {file_path}")
+                            return file_path, None
+                        
+                        result = await self._process_text_async(
+                            text=config["content"],
+                            prompt_type=prompt_type,
+                            scenario=scenario,
+                            max_length=config.get("max_length"),
+                            context_info=config.get("context_info")
+                        )
+                    else:
+                        logger.warning(f"Unsupported media type: {media_type}")
+                        return file_path, None
+                    
+                    return file_path, result
+                    
+                except Exception as e:
+                    logger.error(f"Failed to process file {file_path}: {str(e)}")
+                    return file_path, None
+        
+        # 创建所有任务
+        tasks = [
+            process_single_file(file_path, config)
+            for file_path, config in files.items()
+        ]
+        
+        # 并行执行所有任务
+        completed_tasks = await asyncio.gather(*tasks)
+        
+        # 整理结果
+        results = dict(completed_tasks)
+        
+        return results
+
+    def generate_batch_captions(self, 
+                              files: Dict[str, Dict[str, Union[str, int]]],
+                              scenario: Optional[str] = None) -> Dict[str, Optional[str]]:
+        """
+        批量生成媒体描述的同步包装器
+        
+        Args:
+            files: 文件配置字典,格式为:
+                  {
+                      "file_path": {
+                          "type": "video"|"image"|"text",
+                          "prompt_type": str,  # 可选
+                          "fps": int,  # 仅视频可用
+                          "content": str,  # 仅文本类型需要
+                          "max_length": int  # 可选,仅文本类型可用
+                      }
+                  }
+            scenario: 场景类型
+            
+        Returns:
+            Dict[str, Optional[str]]: 文件路径或标识符到描述的映射
+        """
+        try:
+            loop = asyncio.get_event_loop()
+        except RuntimeError:
+            loop = asyncio.new_event_loop()
+            asyncio.set_event_loop(loop)
+        # 运行异步方法
+        return loop.run_until_complete(
+            self.generate_batch_captions_async(files, scenario)
+        )
+
+media_captioner: MediaCaptioner = MediaCaptioner()
+
+if __name__ == "__main__":
+    async def main():
+        # 初始化
+        captioner = MediaCaptioner()
+
+        # 处理文本
+        text_content = """
+        近日,研究人员在深海发现了一种新的海洋生物物种。
+        这种生物具有独特的生物发光能力,可以在完全黑暗的环境中发出蓝绿色的光。
+        科学家们认为,这一发现对于了解深海生态系统具有重要意义。
+        """
+        
+        # 批量处理示例
+        files = {
+            "./test_data/sample_video.mp4": {
+                "type": "video",
+                "prompt_type": "caption",
+                "fps": 2
+            },
+            "./test_data/sample_image.jpg": {
+                "type": "image",
+                "prompt_type": "caption"
+            },
+            "text_sample": {
+                "type": "text",
+                "content": text_content,
+                "prompt_type": "summary",
+                "max_length": 200
+            }
+        }
+        
+        # 异步批量处理
+        results = await captioner.generate_batch_captions_async(
+            files, 
+            scenario="academic",
+            max_concurrent=5
+        )
+        
+        print("批量处理结果:", results)
+
+    # 运行异步主函数
+    asyncio.run(main())

+ 159 - 0
tools/video_composer.py

@@ -0,0 +1,159 @@
+import os
+import time
+import threading
+from pathlib import Path
+from typing import Optional, List, Dict, Any, Callable, Union
+from enum import Enum
+from dataclasses import dataclass, field
+from tqdm import tqdm
+from moviepy.editor import VideoFileClip, concatenate_videoclips
+import os
+import json
+from modules.media_process.media_processor import media_processor
+from utils.tools import (
+    read_json_file,
+    string_to_json,
+    save_json_file,
+    convert_webp_to_png
+)
+from utils.upload import upload_file_to_tos
+from utils.logger_config import setup_logger
+
+logger = setup_logger(__name__)
+
+class VideoComposer:
+    """视频合成器"""
+    
+    def compose(self, script_path: str) -> str:
+        """
+        将视频片段合成为完整视频
+        
+        Args:
+            script_path: 脚本文件路径
+            
+        Returns:
+            最终视频文件名
+            
+        Raises:
+            FileNotFoundError: 当脚本文件不存在时
+            ValueError: 当脚本数据无效或视频片段不存在时
+        """
+        if not os.path.exists(script_path):
+            raise FileNotFoundError(f"脚本文件不存在: {script_path}")
+        
+        # 读取脚本
+        video_script_data = read_json_file(script_path)
+
+        if not video_script_data:
+            raise ValueError("无法读取脚本文件或文件为空")
+        
+        storyboards = video_script_data[0].get("storyboards", [])
+        if not storyboards:
+            raise ValueError("脚本中未找到分镜详情")
+
+        lens_details = []
+        for storyboard in storyboards:
+            clip_path = storyboard.get("storyboard")
+            lens_details.append(clip_path)
+
+        lens_details = [item for sublist in lens_details for item in sublist]
+        
+        # 收集所有视频片段路径
+        clips = []
+        for lens_item in lens_details:
+            clip_path = lens_item.get("clip_path")
+            if not clip_path:
+                logger.warning(f"分镜 {lens_item.get('lens_id')} 缺少视频片段路径")
+                continue
+            
+            if not os.path.exists(clip_path):
+                logger.warning(f"视频片段不存在: {clip_path}")
+                continue
+            
+            clips.append(clip_path)
+        
+        if not clips:
+            raise ValueError("未找到有效的视频片段")
+        
+        logger.info(f"开始拼接 {len(clips)} 个视频片段")
+        logger.debug(f"视频片段列表: {clips}")
+        
+        # 生成最终视频文件名
+        film_path = f"./output/final_video.mp4"
+        
+        try:
+            media_processor.concat_videos(clips, film_path)
+            logger.info(f"视频合成完成: {film_path}")
+            return film_path
+        except Exception as e:
+            logger.error(f"视频合成失败: {e}")
+            raise
+
+video_composer = VideoComposer()
+
+
+
+def concat_videos(json_path, output_path):
+    """
+    拼接多个视频文件为一个视频。
+
+    :param video_paths: 视频文件路径列表,例如 ['1.mp4', '2.mp4']
+    :param output_path: 输出视频文件路径,例如 'output.mp4'
+    """
+
+    with open(json_path, "r", encoding='utf-8') as f:
+        final_storyboards = json.load(f)["storyboards"]
+
+    segments = []
+    for storyboard in final_storyboards:
+        storyboard_path = storyboard["storyboard"]
+
+        for item in storyboard_path:
+            clip_path = item["clip_path"]
+            segments.append(clip_path)
+    
+    if not segments:
+        raise ValueError("视频路径列表不能为空")
+
+    clips = []
+    for path in segments:
+        if not os.path.isfile(path):
+            raise FileNotFoundError(f"视频文件不存在: {path}")
+        clips.append(VideoFileClip(path))
+
+    # 拼接所有视频片段
+    final_clip = concatenate_videoclips(clips, method="compose")  # 使用 compose 可处理不同尺寸
+
+    # 写入输出文件
+    final_clip.write_videofile(
+        output_path,
+        codec='libx264',
+        audio_codec='aac',
+        temp_audiofile='temp-audio.m4a',
+        remove_temp=True
+    )
+
+    # 关闭所有 clip 以释放资源
+    for clip in clips:
+        clip.close()
+    final_clip.close()
+
+# 示例用法
+if __name__ == "__main__":
+
+
+    # 生成视频片段
+    with open("./output/storyboards_with_segments.json", "r", encoding='utf-8') as f:
+        final_storyboards = json.load(f)[0]["storyboards"]
+
+    segments = []
+    for storyboard in final_storyboards:
+        storyboard_path = storyboard["storyboard"]
+
+        for item in storyboard_path:
+            clip_path = item["clip_path"]
+            segments.append(clip_path)
+
+    concat_videos("./output/storyboards_with_segments.json", "output_combined_4.mp4")
+
+

+ 249 - 0
tools/video_generator.py

@@ -0,0 +1,249 @@
+import os
+import time
+import threading
+from pathlib import Path
+from typing import Optional, List, Dict, Any, Callable, Union
+from tqdm import tqdm
+from dataclasses import dataclass, field
+
+from modules.media_understanding.media_captioner import media_captioner
+from modules.media_process.media_processor import media_processor
+from modules.media_generate.media_generator import (
+    video_create,
+    handle_video_result
+)
+from utils.tools import (
+    read_json_file,
+    save_json_file,
+)
+
+from utils.logger_config import setup_logger
+
+logger = setup_logger(__name__)
+
+@dataclass
+class VideoGenerationConfig:
+    """视频生成配置类"""
+    # 路径配置
+    output_base_dir: str = "./output"
+    
+    # 视频生成参数
+    video_resolution: str = "1080p"
+    video_ratio: str = "16:9"
+    watermark_enabled: bool = False
+    crop_frame: bool = False
+    
+    # 超时配置
+    video_generation_timeout: int = 3600  # 秒
+    polling_interval: int = 5  # 秒
+    
+    # 脚本生成参数
+    script_prompt_type: str = "script"
+    script_scenario: str = "video"
+    prompt_optimization_prefix: str = "待打磨优化的提示词:"
+
+class VideoClipGenerator:
+    """视频片段生成器"""
+    
+    def __init__(self, config: VideoGenerationConfig):
+        self.config = config
+        self._completed_tasks = 0
+        self._lock = threading.Lock()
+    
+    def generate(self, video_script_data: dict) -> bool:
+        """
+        生成所有视频片段
+        
+        Args:
+            script_path: 脚本文件路径
+            
+        Returns:
+            是否全部成功完成
+            
+        Raises:
+            FileNotFoundError: 当脚本文件不存在时
+            ValueError: 当脚本数据无效时
+        """
+        # if not os.path.exists(script_path):
+        #     raise FileNotFoundError(f"脚本文件不存在: {script_path}")
+        
+        # # 读取脚本
+        # video_script_data = read_json_file(script_path)
+        # if not video_script_data:
+        #     raise ValueError("无法读取脚本文件或文件为空")
+        
+        lens_details = []
+        storyboards = video_script_data.get("storyboards", [])
+        for storyboard in storyboards:
+            item_info = storyboard.get("storyboard", [])
+            lens_details.append(item_info)
+
+        lens_details = [item for sublist in lens_details for item in sublist]
+
+        if not storyboards:
+            raise ValueError("脚本中未找到分镜详情")
+        
+        total_tasks = len(lens_details)
+        self._completed_tasks = 0
+        
+        logger.info(f"开始生成 {total_tasks} 个视频片段")
+        
+        # 创建所有视频生成任务
+        for idx, lens_item in enumerate(tqdm(lens_details, desc="提交视频任务")):
+            self._create_video_task(lens_item, idx, total_tasks)
+        
+        # 保存脚本(包含任务ID)
+        # save_json_file(video_script_data, script_path)
+        
+        # 等待所有任务完成
+        return video_script_data, self._wait_for_completion(total_tasks)
+    
+    def _create_video_task(
+        self,
+        lens_item: Dict[str, Any],
+        task_index: int,
+        total_tasks: int
+    ) -> None:
+        """
+        创建单个视频生成任务
+        
+        Args:
+            lens_item: 分镜详情字典
+            script_path: 脚本文件路径
+            task_index: 任务索引
+            total_tasks: 总任务数
+        """
+        lens_id = lens_item.get("idx")
+        motion_prompt = lens_item.get("motion_desc")
+        image_url = lens_item.get("ff_path")
+        lens_duration = 4
+
+        if not all([motion_prompt, image_url, lens_duration]):
+            logger.warning(f"分镜 {lens_id} 缺少必要信息,跳过")
+            return
+        
+        try:
+            # 构建生成参数
+            gen_params = self._build_gen_params(lens_duration)
+            video_filename = os.path.basename(lens_item["ff_path"]).replace(".png", ".mp4")
+            logger.info(f"正在生成视频片段 {lens_id}: {video_filename}")
+
+            # 创建完成事件
+            completion_event = threading.Event()
+            
+            # 包装回调函数
+            wrapped_callback = self._create_callback_wrapper(
+                handle_video_result,
+                completion_event,
+                task_index,
+                total_tasks
+            )
+            
+            # 提交异步任务
+            task_id = video_create.create_video_task_async(
+                prompt=motion_prompt,
+                image_url=image_url,
+                gen_params=gen_params,
+                filename=video_filename,
+                callback=wrapped_callback
+            )
+            
+            if task_id:
+                lens_item["clip_path"] = f"./output/{video_filename}"
+                lens_item["task_id"] = task_id
+                logger.info(f"视频任务 {task_index + 1}/{total_tasks} 已提交: {task_id}")
+            else:
+                logger.error(f"视频任务 {task_index + 1}/{total_tasks} 提交失败")
+                
+        except Exception as e:
+            logger.error(f"创建视频任务时出错: {e}")
+    
+    def _build_gen_params(self, duration: float) -> str:
+        """
+        构建视频生成参数字符串
+        
+        Args:
+            duration: 视频时长
+            
+        Returns:
+            参数字符串
+        """
+        return (
+            f"--rs {self.config.video_resolution} "
+            f"--rt {self.config.video_ratio} "
+            f"--dur {duration} "
+            f"--wm {'true' if self.config.watermark_enabled else 'false'} "
+            f"--cf {'true' if self.config.crop_frame else 'false'}"
+        )
+    
+    def _create_callback_wrapper(
+        self,
+        original_callback: Callable,
+        event: threading.Event,
+        task_index: int,
+        total_tasks: int
+    ) -> Callable:
+        """
+        创建回调函数包装器
+        
+        Args:
+            original_callback: 原始回调函数
+            event: 完成事件
+            task_index: 任务索引
+            total_tasks: 总任务数
+            
+        Returns:
+            包装后的回调函数
+        """
+        def wrapper(*args, **kwargs):
+            try:
+                # 调用原始回调
+                if original_callback:
+                    original_callback(*args, **kwargs)
+            except Exception as e:
+                logger.error(f"回调函数执行出错: {e}")
+            finally:
+                # 标记任务完成
+                event.set()
+                with self._lock:
+                    self._completed_tasks += 1
+                    logger.info(f"视频任务 {task_index + 1}/{total_tasks} 完成 "
+                              f"({self._completed_tasks}/{total_tasks})")
+        
+        return wrapper
+    
+    def _wait_for_completion(self, total_tasks: int) -> bool:
+        """
+        等待所有任务完成
+        
+        Args:
+            total_tasks: 总任务数
+            
+        Returns:
+            是否全部成功完成
+        """
+        logger.info(f"等待 {total_tasks} 个视频任务完成...")
+        
+        start_time = time.time()
+        timeout = self.config.video_generation_timeout
+        
+        while self._completed_tasks < total_tasks:
+            elapsed = time.time() - start_time
+            
+            if elapsed > timeout:
+                logger.error(f"视频生成超时({timeout}秒),"
+                           f"已完成 {self._completed_tasks}/{total_tasks} 个任务")
+                return False
+            
+            remaining = total_tasks - self._completed_tasks
+            if remaining > 0:
+                logger.info(f"等待中... 剩余任务: {remaining}, "
+                          f"已耗时: {int(elapsed)}秒")
+            
+            time.sleep(self.config.polling_interval)
+        
+        logger.info("所有视频生成任务已完成")
+        return True
+
+
+video_generator = VideoClipGenerator(VideoGenerationConfig())

+ 278 - 0
utils/config_manager.py

@@ -0,0 +1,278 @@
+import os
+import importlib.util
+from typing import Dict, Any, Optional
+import yaml
+from utils.logger_config import setup_logger
+
+logger = setup_logger(__name__)
+
+class ConfigManager:
+    """配置管理器,用于加载和管理提示词配置"""
+    
+    def __init__(self, config_path: Optional[str] = None):
+        """
+        初始化配置管理器
+        
+        Args:
+            config_path: 配置文件路径,如果为None则使用默认路径
+        """
+        self.config_path = config_path or os.path.join(
+            os.path.dirname(os.path.dirname(__file__)), 
+            "config", 
+            "prompts.py"
+        )
+        self.config: Dict[str, Any] = {}
+        self.load_config()
+    
+    def load_config(self) -> None:
+        """加载配置文件"""
+        try:
+            # 检查是否为Python配置文件
+            if self.config_path.endswith('.py'):
+                self._load_py_config()
+            else:
+                self._load_yaml_config()
+                
+        except Exception as e:
+            logger.error(f"Failed to load config: {str(e)}")
+            raise
+    
+    def _load_yaml_config(self) -> None:
+        """加载YAML配置文件"""
+        if not os.path.exists(self.config_path):
+            logger.error(f"Config file not found: {self.config_path}")
+            raise FileNotFoundError(f"Config file not found: {self.config_path}")
+        
+        with open(self.config_path, 'r', encoding='utf-8') as f:
+            self.config = yaml.safe_load(f)
+            logger.info(f"Successfully loaded YAML config from {self.config_path}")
+    
+    def _load_py_config(self) -> None:
+        """从Python文件加载配置"""
+        if not os.path.exists(self.config_path):
+            logger.error(f"Config file not found: {self.config_path}")
+            raise FileNotFoundError(f"Config file not found: {self.config_path}")
+        
+        # 使用importlib加载Python模块
+        spec = importlib.util.spec_from_file_location("config_module", self.config_path)
+        if spec is None:
+            raise ImportError(f"Could not load spec from {self.config_path}")
+        
+        module = importlib.util.module_from_spec(spec)
+        if spec.loader is not None:
+            spec.loader.exec_module(module)
+        
+        # 解析Python配置文件中的分层结构
+        py_config = self._parse_py_config(module)
+        
+        # 构建与YAML配置相同的结构
+        self.config = py_config
+        logger.info(f"Successfully loaded Python config from {self.config_path}")
+    
+    def _parse_py_config(self, module) -> Dict[str, Any]:
+        """
+        解析Python配置文件中的分层结构
+        
+        Args:
+            module: 加载的Python模块
+            
+        Returns:
+            Dict: 解析后的配置字典
+        """
+        # 初始化配置结构
+        config = {
+            "defaults": {
+                "video": {},
+                "image": {},
+                "text": {}
+            },
+            "scenarios": {
+                "news": {
+                    "video": {},
+                    "image": {},
+                    "text": {}
+                },
+                "entertainment": {
+                    "video": {},
+                    "image": {},
+                    "text": {}
+                },
+                "academic": {
+                    "video": {},
+                    "image": {},
+                    "text": {}
+                }
+            }
+        }
+        
+        # 查找模块中的所有大写变量并分类
+        for attr_name in dir(module):
+            if attr_name.isupper() and not attr_name.startswith('_'):  # 只获取大写的变量名
+                attr_value = getattr(module, attr_name)
+                if isinstance(attr_value, str):
+                    # 根据变量名前缀分类到不同的媒体类型和场景
+                    self._categorize_config_item(config, attr_name, attr_value)
+        
+        return config
+    
+    def _categorize_config_item(self, config: Dict, attr_name: str, attr_value: str) -> None:
+        """
+        根据变量名将配置项分类到适当的媒体类型和场景中
+        
+        Args:
+            config: 配置字典
+            attr_name: 变量名
+            attr_value: 变量值
+        """
+        # 转换为小写以便比较
+        name_lower = attr_name.lower()
+        
+        # 确定场景
+        scenario = None
+        if 'news' in name_lower:
+            scenario = 'news'
+        elif 'entertainment' in name_lower:
+            scenario = 'entertainment'
+        elif 'academic' in name_lower:
+            scenario = 'academic'
+        
+        # 确定媒体类型
+        media_type = 'text'  # 默认为text
+        if 'video' in name_lower:
+            media_type = 'video'
+        elif 'image' in name_lower:
+            media_type = 'image'
+        
+        # 确定提示词类型(去除前缀后的部分)
+        prompt_type = self._extract_prompt_type(attr_name, scenario, media_type)
+        
+        # 将配置项放入相应的位置
+        if scenario:
+            config['scenarios'][scenario][media_type][prompt_type] = attr_value
+        else:
+            config['defaults'][media_type][prompt_type] = attr_value
+    
+    def _extract_prompt_type(self, attr_name: str, scenario: Optional[str], media_type: str) -> str:
+        """
+        从变量名中提取提示词类型
+        
+        Args:
+            attr_name: 变量名
+            scenario: 场景类型
+            media_type: 媒体类型
+            
+        Returns:
+            str: 提示词类型
+        """
+        # 移除场景前缀
+        name = attr_name
+        if scenario:
+            scenario_prefix = scenario.upper()
+            if name.startswith(scenario_prefix):
+                name = name[len(scenario_prefix):]
+        
+        # 移除媒体类型前缀
+        media_prefix = media_type.upper()
+        if name.startswith(media_prefix):
+            name = name[len(media_prefix):]
+        
+        # 移除下划线分隔符
+        if name.startswith('_'):
+            name = name[1:]
+        
+        # 转换为小写作为提示词类型
+        return name.lower() if name else 'default'
+    
+    def get_prompt(self, 
+                  media_type: str, 
+                  prompt_type: str = "caption",
+                  scenario: Optional[str] = None) -> str:
+        """
+        获取指定类型的提示词
+        
+        Args:
+            media_type: 媒体类型 ('video' 或 'image' 或 'text')
+            prompt_type: 提示词类型 (如 'caption', 'scene' 等)
+            scenario: 场景类型 (如 'news', 'entertainment' 等)
+            
+        Returns:
+            str: 提示词
+            
+        Raises:
+            KeyError: 指定的配置不存在
+        """
+        try:
+            # 如果指定了场景,优先使用场景特定配置
+            if scenario and scenario in self.config.get("scenarios", {}):
+                scenario_config = self.config["scenarios"][scenario]
+                if media_type in scenario_config and prompt_type in scenario_config[media_type]:
+                    result = scenario_config[media_type][prompt_type]
+                    # 如果是从Python文件加载的,提取content字段
+                    if isinstance(result, dict) and "content" in result:
+                        return result["content"]
+                    return str(result)
+            
+            # 回退到默认配置
+            if prompt_type in self.config["defaults"][media_type]:
+                prompt_data = self.config["defaults"][media_type][prompt_type]
+                # 如果是从Python文件加载的,提取content字段
+                if isinstance(prompt_data, dict) and "content" in prompt_data:
+                    return prompt_data["content"]
+                return str(prompt_data)
+            
+            raise KeyError(f"Prompt not found for {media_type}/{prompt_type}")
+            
+        except KeyError as e:
+            logger.error(f"Failed to get prompt: {str(e)}")
+            # 返回基础提示词作为后备
+            return ("视频里有什么?" if media_type == "video" else "请描述图片内容")
+            
+    def get_all_prompts(self, 
+                       media_type: str,
+                       scenario: Optional[str] = None) -> Dict[str, str]:
+        """
+        获取指定媒体类型的所有提示词
+        
+        Args:
+            media_type: 媒体类型 ('video' 或 'image' 或 'text')
+            scenario: 场景类型 (如 'news', 'entertainment' 等)
+            
+        Returns:
+            Dict[str, str]: 提示词类型到提示词的映射
+        """
+        prompts = {}
+        
+        # 获取默认配置
+        if media_type in self.config["defaults"]:
+            for key, value in self.config["defaults"][media_type].items():
+                # 如果是从Python文件加载的,提取content字段
+                if isinstance(value, dict) and "content" in value:
+                    prompts[key] = value["content"]
+                else:
+                    prompts[key] = str(value)
+        
+        # 如果指定了场景,添加或覆盖场景特定配置
+        if scenario and scenario in self.config.get("scenarios", {}):
+            scenario_config = self.config["scenarios"][scenario]
+            if media_type in scenario_config:
+                for key, value in scenario_config[media_type].items():
+                    # 如果是从Python文件加载的,提取content字段
+                    if isinstance(value, dict) and "content" in value:
+                        prompts[key] = value["content"]
+                    else:
+                        prompts[key] = str(value)
+        
+        return prompts
+
+
+if __name__ == "__main__":
+    # 创建ConfigManager实例
+    config_manager = ConfigManager()
+    
+    # 获取所有提示词
+    all_prompts = config_manager.get_all_prompts("video")
+    print(all_prompts)
+    
+    # 获取指定类型的提示词
+    caption_prompt = config_manager.get_prompt("video", "script")
+    print(caption_prompt)

+ 62 - 0
utils/logger_config.py

@@ -0,0 +1,62 @@
+import os
+import logging
+from colorama import Fore, Style, init
+
+current_dir = os.path.dirname(os.path.abspath(__file__))
+log_path = os.path.join(current_dir, "..", "logs", "video_create.log")
+
+# 初始化 colorama
+init(autoreset=True)
+
+class ColoredFormatter(logging.Formatter):
+    COLORS = {
+        'DEBUG': Fore.CYAN,
+        'INFO': Fore.GREEN,
+        'WARNING': Fore.YELLOW,
+        'ERROR': Fore.RED,
+        'CRITICAL': Fore.RED + Style.BRIGHT,
+    }
+
+    def format(self, record):
+        levelname = record.levelname
+        # 添加文件名和行号到日志记录
+        record.filename = os.path.basename(record.pathname)  # 仅显示文件名
+        record.location = f"{record.filename}:{record.lineno}"
+        
+        if levelname in self.COLORS:
+            color = self.COLORS[levelname]
+            record.levelname = f"{color}{levelname}{Style.RESET_ALL}"
+            record.msg = f"{color}{record.msg}{Style.RESET_ALL}"
+        return super().format(record)
+
+def setup_logger(name):
+    # 配置日志
+    logger = logging.getLogger(name)
+    logger.setLevel(logging.DEBUG)
+
+    # 创建控制台处理器
+    console_handler = logging.StreamHandler()
+    console_handler.setLevel(logging.DEBUG)
+
+    # 创建文件处理器
+    file_handler = logging.FileHandler(log_path, encoding='utf-8') 
+    file_handler.setLevel(logging.DEBUG) 
+
+    # 创建格式化器 - 添加详细位置信息 [文件名:行号]
+    console_formatter = ColoredFormatter(
+        '%(asctime)s - %(levelname)s - [%(location)s] %(funcName)s() - %(message)s'
+    )
+    
+    # 文件处理器使用非彩色格式
+    file_formatter = logging.Formatter(
+        '%(asctime)s - %(levelname)s - [%(filename)s:%(lineno)d] %(funcName)s() - %(message)s'
+    )
+
+    console_handler.setFormatter(console_formatter)
+    file_handler.setFormatter(file_formatter)
+
+    # 将处理器添加到日志记录器
+    logger.addHandler(console_handler)
+    logger.addHandler(file_handler) 
+
+    return logger

+ 9 - 0
utils/retry.py

@@ -0,0 +1,9 @@
+import tenacity
+import traceback
+import logging
+
+def after_func(retry_state: tenacity.RetryCallState) -> None:
+    if retry_state.outcome.failed:
+        exc = retry_state.outcome.exception()
+        logging.warning(f"Retrying {retry_state.fn.__name__} due to {repr(exc)} (Attempt {retry_state.attempt_number})")
+        logging.debug(traceback.format_exception(type(exc), exc, exc.__traceback__))

+ 333 - 0
utils/tools.py

@@ -0,0 +1,333 @@
+import json
+import re
+import io
+import os
+import base64
+import requests
+import subprocess
+import shutil
+from PIL import Image
+from .logger_config import setup_logger
+
+logger = setup_logger(__name__)
+
+
+def string_to_json(markdown_string):
+    try:
+        json_content = re.sub(r'^```json|\n```$', '', markdown_string, flags=re.MULTILINE).strip()
+        if not json_content:
+            json_content = markdown_string
+            # raise ValueError("字符串中未找到有效的JSON内容")
+            
+        # 解析JSON内容
+        json_data = json.loads(json_content)
+        
+        return json_data
+        
+    except Exception as e:
+        logger.info(f"生成结果解析失败:\n{markdown_string}")
+
+def save_json_file(json_data, output_file):
+    try:
+        with open(output_file, mode='w', encoding='utf-8') as f:
+            json.dump(json_data, f, ensure_ascii=False, indent=4)
+            logger.info(f"JSON文件保存成功:{output_file}")
+        return output_file
+    except Exception as e:
+        logger.info(f"处理过程中出错: {e}")
+        return False
+
+def save_string_as_json(markdown_string, output_file):
+    """
+    从Markdown格式的字符串中提取JSON内容并保存为JSON文件
+    
+    参数:
+        markdown_string (str): 包含Markdown代码块的字符串
+        output_file (str): 要保存的JSON文件路径
+        
+    返回:
+        bool: 保存成功返回True,失败返回False
+    """
+    json_data = string_to_json(markdown_string)
+    result = save_json_file(json_data, output_file)
+    return result
+
+def encode_image(image_path: str) -> str:
+    """
+    将图片文件转换为base64编码
+    
+    Args:
+        image_path: 图片文件路径
+        
+    Returns:
+        str: base64编码的图片数据
+        
+    Raises:
+        FileNotFoundError: 图片文件不存在
+        IOError: 读取或处理图片失败
+    """
+    if not os.path.exists(image_path):
+        raise FileNotFoundError(f"Image file not found: {image_path}")
+        
+    with Image.open(image_path) as img:
+        buffered = io.BytesIO()
+        img.save(buffered, format="JPEG")
+        return base64.b64encode(buffered.getvalue()).decode("utf-8")
+
+def encode_video(video_path: str) -> str:
+    """
+    将视频文件转换为base64编码
+    
+    Args:
+        video_path: 视频文件路径
+        
+    Returns:
+        str: base64编码的视频数据
+        
+    Raises:
+        FileNotFoundError: 视频文件不存在
+        IOError: 读取文件失败
+    """
+    if not os.path.exists(video_path):
+        raise FileNotFoundError(f"Video file not found: {video_path}")
+        
+    with open(video_path, "rb") as f:
+        return base64.b64encode(f.read()).decode("utf-8")
+
+def download_image(image_url, output_path):
+    """
+    根据图片URL下载图片到本地指定路径
+    
+    参数:
+        image_url (str): 图片的URL地址
+        output_path (str): 本地保存路径(包含文件名和扩展名)
+        
+    返回:
+        bool: 下载成功返回True,失败返回False
+    """
+    try:
+        # 创建目录(如果不存在)
+        os.makedirs(os.path.dirname(output_path), exist_ok=True)
+        
+        # 发送HTTP GET请求
+        response = requests.get(image_url, stream=True)
+        response.raise_for_status()  # 检查请求是否成功
+        
+        # 以二进制写入模式保存图片
+        with open(output_path, 'wb') as f:
+            for chunk in response.iter_content(1024):
+                f.write(chunk)
+                
+        logger.info(f"图片已成功保存到: {output_path}")
+        return True
+        
+    except requests.exceptions.RequestException as e:
+        logger.info(f"下载图片时出错: {e}")
+    except IOError as e:
+        logger.info(f"保存图片时出错: {e}")
+    except Exception as e:
+        logger.info(f"发生未知错误: {e}")
+        
+    return False
+
+def download_video(video_url, output_path):
+    """
+    根据视频URL下载视频到本地指定路径
+    
+    参数:
+        video_url (str): 视频的URL地址
+        output_path (str): 本地保存路径(包含文件名和扩展名)
+        
+    返回:
+        bool: 下载成功返回True,失败返回False
+    """
+    try:
+        # 创建目录(如果不存在)
+        os.makedirs(os.path.dirname(output_path), exist_ok=True)
+        
+        # 发送HTTP GET请求
+        headers = {
+            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3'
+        }
+        response = requests.get(video_url, headers=headers, stream=True)
+        response.raise_for_status()  # 检查请求是否成功
+        
+        # 获取文件总大小(用于进度显示)
+        total_size = int(response.headers.get('content-length', 0))
+        
+        # 以二进制写入模式保存视频
+        with open(output_path, 'wb') as f:
+            downloaded_size = 0
+            for chunk in response.iter_content(chunk_size=8192):
+                if chunk:  # 过滤掉保持连接的新块
+                    f.write(chunk)
+                    downloaded_size += len(chunk)            
+                        
+        logger.info(f"\n视频已成功保存到: {output_path}")
+        return True
+        
+    except requests.exceptions.RequestException as e:
+        logger.info(f"下载视频时出错: {e}")
+    except IOError as e:
+        logger.info(f"保存视频时出错: {e}")
+    except Exception as e:
+        logger.info(f"发生未知错误: {e}")
+        
+    return False
+
+# 读取JONS文件
+def read_json_file(file_path):
+    try:
+        with open(file_path, 'r', encoding='utf-8') as json_file:
+            data = json.load(json_file)
+            return data
+    except FileNotFoundError:
+        logger.info(f"文件 {file_path} 未找到。")
+    except json.JSONDecodeError:
+        logger.info(f"文件 {file_path} 不是有效的JSON格式。")
+    except Exception as e:
+        logger.info(f"发生错误: {e}")
+
+def convert_webp_to_png(webp_path, png_path):
+    """
+    Convert a WebP image to a PNG image.
+
+    :param webp_path: Path to the input WebP file.
+    :param png_path: Path where the output PNG file will be saved.
+    """
+    # Open the WebP image file
+    with Image.open(webp_path) as img:
+        # Convert the image to RGB mode if necessary (some WebPs are in RGBA)
+        if img.mode == 'RGBA':
+            img = img.convert('RGB')
+        
+        # Save the image in PNG format
+        img.save(png_path, 'PNG')
+
+def compress_video(input_path, output_path, crf=23, preset='medium'):
+    """
+    压缩视频文件
+    
+    参数:
+        input_path (str): 输入视频文件路径
+        output_path (str): 输出压缩后视频的保存路径
+        crf (int): 恒定速率因子,值越小质量越好(范围0-51,默认23)
+        preset (str): 编码速度/压缩率的权衡(ultrafast, superfast, veryfast, fast, medium, slow, slower, veryslow)
+                      越慢压缩率越高,但耗时更长(默认medium)
+    返回:
+        bool: 压缩成功返回True,否则返回False
+    """
+    # 检查ffmpeg是否安装
+    if not shutil.which('ffmpeg'):
+        print("错误:未找到ffmpeg,请先安装ffmpeg并确保其在系统PATH中")
+        return False
+    
+    # 检查输入文件是否存在
+    if not os.path.exists(input_path):
+        print(f"错误:输入文件不存在 - {input_path}")
+        return False
+    
+    # 创建输出目录(如果不存在)
+    output_dir = os.path.dirname(output_path)
+    if output_dir and not os.path.exists(output_dir):
+        os.makedirs(output_dir, exist_ok=True)
+    
+    # ffmpeg命令:使用H.264编码压缩视频,保持原音频质量
+    cmd = [
+        'ffmpeg',
+        '-i', input_path,         # 输入文件
+        '-vcodec', 'libx264',     # 视频编码器
+        '-crf', str(crf),         # 恒定速率因子
+        '-preset', preset,        # 编码预设
+        '-acodec', 'copy',        # 复制音频流(不重新编码)
+        '-y',                     # 覆盖输出文件
+        output_path               # 输出文件
+    ]
+    
+    try:
+        # 执行命令
+        result = subprocess.run(
+            cmd,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+            text=True
+        )
+        
+        # 检查执行结果
+        if result.returncode != 0:
+            print(f"压缩失败:{result.stderr}")
+            return False
+        
+        print(f"视频压缩成功,已保存至:{output_path}")
+        return True
+        
+    except Exception as e:
+        print(f"压缩过程中发生错误:{str(e)}")
+        return False
+
+
+def efficient_sort(a, b):
+    """
+    高效排序算法,通过贪心策略
+    """
+    n = len(a)
+    selected = [False] * n
+    result = []
+    available = set()
+    
+    # 统计每个元素的依赖
+    dependencies = []
+    for i in range(n):
+        # 如果a[i]在b[i]中,它可以立即被处理
+        immediate = a[i] in b[i]
+        dependencies.append((immediate, i))
+    
+    # 先处理可以立即处理的元素
+    for immediate, i in dependencies:
+        if immediate and not selected[i]:
+            result.append(i)
+            selected[i] = True
+            available.update(b[i])
+    
+    # 然后处理其他元素
+    while len(result) < n:
+        progress = False
+        
+        for i in range(n):
+            if not selected[i] and a[i] in available:
+                result.append(i)
+                selected[i] = True
+                available.update(b[i])
+                progress = True
+        
+        # 如果没有进展,选择第一个未处理的元素
+        if not progress:
+            for i in range(n):
+                if not selected[i]:
+                    result.append(i)
+                    selected[i] = True
+                    available.update(b[i])
+                    break
+    
+    return result
+
+# 使用示例
+if __name__ == "__main__":
+    # markdown_str = """
+    # {
+    #     "name": "李四",
+    #     "age": 25,
+    #     "address": {
+    #         "city": "上海",
+    #         "district": "浦东新区"
+    #     },
+    #     "hobbies": ["阅读", "编程", "旅行"]
+    # }
+    # """
+    # save_markdown_json_as_file(markdown_str, 'output.json')
+
+    # 示例调用
+    image_url = "https://ark-project.tos-cn-beijing.volces.com/doc_image/seedream4_imageToimage.png"
+    output_path = "./output/my_image.jpg"  # 可以是相对路径或绝对路径
+
+    download_image(image_url, output_path)

+ 94 - 0
utils/upload.py

@@ -0,0 +1,94 @@
+import os
+import tos
+
+from dotenv import load_dotenv
+from utils.logger_config import setup_logger
+
+logger = setup_logger(__name__)
+
+# 加载环境变量
+load_dotenv()
+
+
+# 从环境变量获取 AK 和 SK 信息
+ak = os.getenv('TOS_ACCESS_KEY')
+sk = os.getenv('TOS_SECRET_KEY')
+
+
+# 存储桶配置信息
+endpoint = "https://tos-cn-guangzhou.volces.com"
+region = "cn-guangzhou" 
+bucket_name = "guide-material"
+
+
+def upload_file_to_tos(file_name: str) -> str:
+    """
+    上传文件到TOS存储桶并返回访问URL
+    
+    Args:
+        file_name (str): 本地文件的完整路径
+        
+    Returns:
+        str: 上传文件的访问URL
+        
+    Raises:
+        Exception: 上传过程中的任何错误
+    """
+    try:
+        # 检查文件是否存在
+        if not os.path.exists(file_name):
+            raise FileNotFoundError(f"文件不存在: {file_name}")
+        
+        # 从文件路径中提取文件名作为object_key,确保使用正斜杠
+        filename = os.path.basename(file_name)
+        object_key = f"video-create/{filename}".replace("\\", "/")
+        logger.info(f'开始上传文件: {file_name}')
+        logger.info(f'文件将保存为: {object_key}')
+        
+        # 创建客户端并上传文件
+        client = tos.TosClientV2(ak, sk, endpoint, region)
+        client.put_object_from_file(bucket_name, object_key, file_name)
+        
+        # 验证上传是否成功 - 尝试获取对象元数据
+        try:
+            head_response = client.head_object(bucket_name, object_key)
+            if head_response is None:
+                raise Exception("无法验证文件是否上传成功:head_object返回None")
+            logger.info(f'文件上传验证成功,ETag: {getattr(head_response, "etag", "N/A")}')
+        except Exception as verify_error:
+            logger.warning(f'验证上传状态时出现警告: {str(verify_error)}')
+            # 不抛出异常,因为上传可能已经成功,只是验证失败
+        
+        # 生成访问URL,确保object_key使用正斜杠
+        object_key_normalized = object_key.replace("\\", "/")
+        object_url = f"https://testdgxcx-oss.gloria.com.cn/{object_key_normalized}"
+        logger.info(f'文件上传成功,访问URL: {object_url}')
+        logger.info(f'Object Key (用于调试): {object_key_normalized}')
+        return object_url
+        
+    except tos.exceptions.TosClientError as e:
+        error_msg = f'上传失败,客户端错误: message={e.message}, cause={e.cause}'
+        logger.error(error_msg)
+        raise Exception(error_msg)
+    except tos.exceptions.TosServerError as e:
+        error_msg = f'上传失败,服务端错误: code={e.code}, request_id={e.request_id}, message={e.message}, status_code={e.status_code}, ec={e.ec}, request_url={e.request_url}'
+        logger.error(error_msg)
+        raise Exception(error_msg)
+    except Exception as e:
+        error_msg = f'上传失败,未知错误: {str(e)}'
+        logger.error(error_msg)
+        raise Exception(error_msg)
+
+# 使用示例
+if __name__ == "__main__":
+
+    # python -m utils.upload
+    test_file = "./output/new_frame_scene0_camera0_shot1.png"
+    # test_file = "/data/data/luosy/project/iclip/output/rawvideo/rawvideo_a/oral_show/final-final-rawvideo-0-0.mp4"
+    # 
+    # test_file = "010.jpg"
+    try:
+        url = upload_file_to_tos(test_file)
+        print(f"文件上传成功,访问URL: {url}")
+    except Exception as e:
+        print(f"上传失败: {str(e)}")