diff --git a/CHANGELOG.md b/CHANGELOG.md index e03338b..9e7e66f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.59.5 — 2026-07-27 + +- 视频生成现在支持让已有图片直接“动起来”:既可以指定一张图片作为视频开场画面,也可以同时提供多张人物、产品、场景或风格参考图(最多 9 张),生成时更容易保持主体和视觉风格一致。 + ## 0.59.4 — 2026-07-24 - 对话里直接说「把这份资料放进我的知识库」现在可靠了:即使你还没建过任何库,助手也会帮你建库并入库(此前会被误存成「记忆」);同时更明确了分工——成篇资料进知识库,关于你本人的偏好等短事实才记进记忆。 diff --git a/PROGRESS.md b/PROGRESS.md index 401368e..9d5ee30 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-07-23(架构审查落地:15 commit 内部重构大扫除 + 测试 286→351 + 测试库基建,bump 0.59.1) +最后更新:2026-07-27(Seedance 图生视频:单图首帧 + 最多 9 张多图参考,bump 0.59.5) --- @@ -23,6 +23,7 @@ ### 2026-07 +- **07-27 / 0.59.5 / Seedance 图生视频(单图首帧 + 最多 9 张多图参考)**:`seedance` 在原文生视频契约上向后兼容增加 `image`(唯一 `first_frame`)与 `reference_images`(0-9 张 `reference_image`),两者合计最多 9 张;复用 image_ref 的 task/user_root 三形态路径解析、越界防护与 base64 内联,新增单图 10MB / 合计 30MB 请求体闸。请求按有无图片切图生/文生单价,banner/meta/usage snapshot 记录 mode、首帧及参考图;videogen skill、工具系统提示、RUN 与能力清单同步。5 个离线单测覆盖单图、多图、文生兼容、越界与超量拒绝。 - **07-24 / 0.59.4 / kb 冷启动契约(修「放进知识库」误存记忆)+ 查看原件 + 条目精简**:①真实用户事故——说「放进我的知识库」被 agent 写进 `.memory/`:根因 `kb_block` 零库返回空串,模型不知道 KB 机制存在(memory 空契约常驻恰是为解冷启动,kb 当时没照抄这课)。修 = 零库注 ~百 token 冷启动契约(建库步骤 / INDEX 行格式 / 「成篇资料进 KB、短事实进记忆」分工;仅用户明确要求才建库),建库落盘后下轮 build_agent 自然切全量注入;skill 方案再否(召回同为概率召回还多一层间接,且违背「机制而非 skill」定稿)。DESIGN §3.8 取舍随 commit 更新,`tests/test_kb_block.py` 4 用例锁两分支。②前端「查看原件」三入口(已入库条目行 👁 / 单篇页头 / 待入库列表),复用 `openFilePreview` 走 `.kb/<库>/sources/` 相对路径零后端;关键层级修正:file-preview-modal z 90→114 盖过 kb/skills/memory 等 112 层 modal(仍低于 app-dialog 130,删除确认在预览上),fp-open chat-form 95→115、mini 96→116,main.js Esc 关栈顺序同步先关预览再关 modal。「像文件目录浏览 .kb」提议对齐后定维持现状:.kb 继续对文件面板隐藏,守住删单篇「doc+原件+INDEX 行」三件联动判据。③已入库条目压两行:标题 + 单行省略摘要(原 `.sk-desc` 省略规则只覆盖左栏 `.sk-item`,右栏整段换行铺开),关键词挪进悬停 title 不丢信息。 - **07-23 / 0.59.3 / 知识库下载入口(原件 + 解析 Markdown,纯前端)**:kb 面板此前只能在线看不能取回。关键发现:`.kb/` 就在 user_root 下且 `/v1/files/download` 的 safe_join 只挡越界不挡 dotfile → 零后端改动,`kb.js` 复用 `media.js::downloadFile`(Bearer + blob)拼 `.kb/<库>/{sources,docs}/…` 路径即可。入口三处:已入库条目行「⬇ 下载原件」(agent 手写无原件的条目不显示)、单篇查看页头「下载原件 / 下载 Markdown」(doc→source 映射渲染详情时记 `_docSources`)、待入库列表逐项「下载」。弃选方案:新增 `/v1/kb/*/sources` 专属下载端点(与 files 下载全量重复,公测期白养一个对外契约)。改动仅 kb.js + dev.html 三行 CSS。 - **07-23 / 0.59.2 / 移动端对话附件入口(📎 拍照 / 照片库 / 选文件)**:chat 输入区此前仅粘贴 / 拖拽两条附件路(移动端双双不可用,手机没有任何传文件入口)。加 📎 按钮:触屏(`pointer: coarse`)弹 showMenu 三项菜单——拍照(`capture="environment"` 直起后置相机)/ 照片库(`accept="image/*,video/*"`)/ 选取文件(无 accept);桌面端点击直开系统文件选择器(拖拽 / 粘贴照旧)。三个隐藏 input 的 change 统一走既有 `uploadAttachFiles()`(chip 托盘 / 发送注入行零改动);change 后清 value 允许连选同一文件;微信 / 企业微信只读镜像随 `applyChannelComposerLock` 禁用按钮 + 点击侧 `_composerLocked()` 双保险。纯前端(dev.html / chat.js),无后端改动。裸 file input 方案(靠 iOS 原生三选单)因 Android 行为不确定被弃,三项菜单跨平台确定。 diff --git a/RUN.md b/RUN.md index f708b7b..ef92071 100644 --- a/RUN.md +++ b/RUN.md @@ -17,7 +17,7 @@ # 豆包(火山方舟)统一 key,三处共用:可选。 # 1) 文本/Agent 模型 config/models/doubao.yaml(Seed 2.1 turbo/pro、自进化 evolving)—— 走 Ark OpenAI 兼容端点 # 2) 图像生成 seedream tool(0.22 元/张) - # 3) 视频生成 seedance tool(Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s) + # 3) 视频生成 seedance tool(Seedance 2.0 Fast,文生/单图首帧/最多9张多图参考,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s) # 未设:豆包文本模型选不了,seedream/seedance 两个 tool 都不出现 ARK_API_KEY=... # documents skill(内部知识库 document_search API):可选。设了后注册 diff --git a/SKILL_LIST.md b/SKILL_LIST.md index 2e08618..5e7538d 100644 --- a/SKILL_LIST.md +++ b/SKILL_LIST.md @@ -1,7 +1,7 @@ # zcbot Skill 清单 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) -最后更新:2026-07-08(新增 rebuttal 审稿回复;paper 补图表纪律与投稿前审计;review 补英文 SCI 润色子模式) +最后更新:2026-07-27(videogen 新增 Seedance 单图首帧与最多 9 张多图参考输入) Skill 总数:18 zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。 @@ -455,13 +455,13 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S **何时用**: - ✅ 用户明确说"做个视频" / "出段视频" / "动画" / "动起来" / "镜头扫过" - ✅ 用户原本要 ppt,主动问能不能配段视频(介绍价格 + 时长引导决策) -- ✅ 拿到 seedream 静态图后说"想动起来"(注意:Phase 1 仅支持 t2v 文生视频,**不支持 i2v** 图生视频) +- ✅ 拿到 seedream 静态图或上传图片后说"想动起来"(首帧传 `image`,多图参考传 `reference_images`) **何时不走本 skill**: - ⛔ 用户没主动要视频 - ⛔ 流程 / 结构动效(节点-箭头逐次出现)→ 是 ppt 动画 / mermaid 的事 - ⛔ 要实拍素材 / 已有视频剪辑 → seedance 是 AI 生成,告诉用户走 unsplash / pexels -- ⛔ 用户有具体参考视频说"按这个改" → Phase 1 不支持 i2v / v2v +- ⛔ 用户有具体参考视频说"按这个改" → 当前支持图片首帧输入,但不支持视频编辑(v2v) **关键岔路**:静态图够用就别上动态(seedream ¥0.22 vs seedance ¥4 起)。 diff --git a/config/media/doubao.yaml b/config/media/doubao.yaml index 29b0c63..0bf1d7b 100644 --- a/config/media/doubao.yaml +++ b/config/media/doubao.yaml @@ -66,8 +66,8 @@ video: endpoint_poll: /contents/generations/tasks # 实际路径 = base + "/{cgt_id}" # 计费(per-token,token = (in_dur+out_dur) × W × H × fps / 1024): - # 文生视频(无视频输入,本期主力路径): ¥37 / 百万 tokens - # 图生视频(有视频输入,phase 2): ¥22 / 百万 tokens + # 文生视频(无视觉输入): ¥37 / 百万 tokens + # 图生视频(有首帧图片输入): ¥22 / 百万 tokens # 实测档位(fast, 5s, 文生视频, 24fps,源: ArcReel 费用参考表): # 480p 16:9 → ¥1.86 # 720p 16:9 → ¥4.00 @@ -86,6 +86,9 @@ video: # 开启会增加 token 消耗(模型还要算音轨),cost 比纯视频高;默认关闭让 cost 可预测, # 用户要带音的视频(广告 / 角色对白)时显式传 true。 default_generate_audio: false + max_image_mb: 10 # 图生视频每张本地图片上限(base64 内联) + max_reference_images: 9 # image 首帧 + reference_images 合计上限 + max_total_image_mb: 30 # 多图原始体积合计上限,防 base64 请求体失控 # 轮询参数 request_timeout_s: 60 # submit POST 超时(异步,只是提交) @@ -115,6 +118,9 @@ video: default_duration: 5 default_watermark: false default_generate_audio: false + max_image_mb: 10 + max_reference_images: 9 + max_total_image_mb: 30 # 轮询参数:Pro 出片慢于 Fast(更精细),拉长超时 request_timeout_s: 60 diff --git a/core/__init__.py b/core/__init__.py index 97ef9ff..6ab3522 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.59.4" +__version__ = "0.59.5" diff --git a/core/agent_builder.py b/core/agent_builder.py index b94e61b..418d717 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -69,9 +69,9 @@ _MEDIA_GPT_IMAGE_SEG = """\ _MEDIA_DIAGRAM_FORK_SEG = """\ - **画"图"先分岔(mermaid vs 生图)**:用户要**流程图 / 架构图 / 技术路线图 / 时序图**这类结构图时,**不要默默替他选路线** —— 先用 `ask_user` 让用户在两条路线里点选:①mermaid 矢量图(结构清晰、文字准、可编辑、零成本);②生图模型视觉版(有质感 / 视觉冲击,但中文标签易乱码、位图不可后期改字,seedream ¥0.22 / GPT 生图 ~40s)。选②后 `load_skill('imagegen')` 再走。**免问直走的例外**:用户已点名工具("用 mermaid" / "用生图画" / "用 GPT 画")→ 照办;本次对话里用户已选过路线 → 沿用不再问;paper / proposal 等 skill 管线内部要求 mermaid 配图的 → 按 skill 规定走。""" _MEDIA_SEEDANCE_SEG = """\ -- `seedance` —— 豆包视频生成(Seedance 2.0 Fast)。异步任务,**等 30-90s 出片**;产物自动落 `/videos/`。每次 **¥1.86 起**(480p 4s)~ **¥12+**(720p 15s),比图贵 10 倍以上。触发词:视频 / 动画 / 动起来 / 做个 video / 镜头 / 短片 / 演示视频 / 动效。 +- `seedance` —— 豆包视频生成(Seedance 2.0 Fast),支持文生视频、单图首帧和最多 9 张多图参考生视频。异步任务,**等 30-90s 出片**;产物自动落 `/videos/`。每次 **¥1.86 起**(480p 4s)~ **¥12+**(720p 15s),比图贵 10 倍以上。触发词:视频 / 动画 / 动起来 / 做个 video / 镜头 / 短片 / 演示视频 / 动效。 - **调用前必须先 `load_skill('videogen')`** —— skill 里有「6 维诊断(含运动维必填)/ seedream/mermaid 反向选型 / prompt 装配 / 参数取舍(时长/分辨率/比例直接决定钱)/ 失败解药」全套引导。视频比图贵 10 倍且 90s 等待,绝对不要拿用户原话当 prompt 直接调。 - - 兜底硬约束:用户没主动要视频就别装饰性生成(比生图更严重的红线);同一目的不满意**绝不连发**(1 次错 = ¥4+60s,连发 2 次 = ¥8+2min);phase 1 仅文生视频,**不支持** image-to-video / video-to-video。""" + - 兜底硬约束:用户没主动要视频就别装饰性生成(比生图更严重的红线);同一目的不满意**绝不连发**(1 次错 = ¥4+60s,连发 2 次 = ¥8+2min);固定开场图传 `image`,多张主体/产品/场景参考图传 `reference_images`;当前不支持 video-to-video。""" def _media_tools_block(has_ark: bool, image_tool: str) -> str: diff --git a/skills/videogen/SKILL.md b/skills/videogen/SKILL.md index a6276c5..268aec3 100644 --- a/skills/videogen/SKILL.md +++ b/skills/videogen/SKILL.md @@ -26,14 +26,14 @@ description: 用豆包 Seedance 2.0 Fast 生视频(`seedance` tool)。**任何 - 用户**明确说**"做个视频" / "出段视频" / "动画" / "动起来" / "镜头扫过" / "演示视频" - 用户原本要 ppt / 海报 / 申报书,**主动**问能不能配段视频(此时介绍价格 + 时长引导决策) -- 用户拿到 seedream 生的静态图后说"想动起来" / "加点运动" — 注意 phase 1 只支持 t2v 文生视频,**不支持**从已有图生视频(i2v),要告诉用户这点 +- 用户拿到 seedream 生的静态图或上传图片后说"想动起来" / "加点运动" → 走图生视频,把该图路径传给 `image` ## 何时不走本 skill - 用户**没主动要视频**(别为"丰富回复"装饰性生视频 —— 比图更严重的浪费红线) - 用户要的是**流程/结构动效**(节点-箭头-步骤逐次出现)→ 这是 ppt 动画 / mermaid + ppt 转场的事,不是视频 - 用户要的是**实拍素材** / **已有视频剪辑** → seedance 是 AI 生成,不是素材库;告诉用户走 unsplash / pexels 等 -- 用户**有具体参考视频说"按这个改"** → phase 1 不支持 i2v / v2v,告诉用户先用文字描述 +- 用户**有具体参考视频说"按这个改"** → 当前只支持图片首帧输入,不支持视频编辑(v2v),告诉用户先截取关键帧或用文字描述 ## 关键岔路:seedream vs seedance vs mermaid @@ -111,6 +111,8 @@ description: 用豆包 Seedance 2.0 Fast 生视频(`seedance` tool)。**任何 > prompt: 工地上正在浇筑混凝土的楼板,混凝土从泵车软管流出注入模板, > 工人手持振动棒来回插入,固定俯视镜头缓慢推近模板中心, > 写实工程纪录片风格,正午阳光 +> image: 不传(文生视频);需要固定首帧时填用户上传图片或 seedream 的 saved 路径 +> reference_images: 不传;多图参考时填图片路径列表(image + reference_images 最多 9 张) > resolution: 720p > ratio: 16:9(ppt 横版) > duration: 5 秒 @@ -194,6 +196,8 @@ prompt 写法变化:开 `generate_audio=true` 时,prompt 里要描述**声音是 ``` seedance( prompt="工地上正在浇筑混凝土的楼板,混凝土从泵车软管流出注入模板,工人手持振动棒来回插入,固定俯视镜头缓慢推近模板中心,写实工程纪录片风格,正午阳光", + image="figures/reference.png", # 可省;传入时作为首帧进行图生视频 + reference_images=["figures/worker.png", "figures/site.png"], # 可省;多图参考 resolution="720p", # 可省,走默认 ratio="16:9", # 可省 duration=5, # 可省 @@ -201,9 +205,17 @@ seedance( ) ``` +图生视频有两个兼容入口: +- `image`:单张 `first_frame`,视频必须从这张画面开始。 +- `reference_images`:0-9 张 `reference_image`,用于约束人物、产品、场景或风格,不强制作为首帧。 + +两者都支持 task_dir 相对路径、用户上传图片路径,以及 seedream 上次返回的 `saved` +路径,合计最多 9 张。prompt 用“参考图片1/2”明确每张图的用途。续查图生视频任务时 +仍带上原 `image` / `reference_images`,用于正确恢复计费模式和产物元数据。 + **调用是同步阻塞 Fast 30-90s / Pro 2-3min** —— tool 内部 submit 后轮询直到 succeeded,期间 LLM 卡住。这不是 bug,告诉用户"提交了,等 30-90 秒"再耐心等返回。 -返回串首行是 `[seedance] model=... · resolution=... · ratio=... · duration=Xs · cost=¥... · elapsed=...s` —— 原样保留给用户(SPA 会 parse 挂徽章)。第二行 `saved: <相对路径>` 是产物路径,告诉用户。 +返回串首行是 `[seedance] model=... · mode=text_to_video|image_to_video · images=N · image=... · resolution=... · ratio=... · duration=Xs · cost=¥... · elapsed=...s` —— `images` 是图片总数,`image` 仅固定首帧时出现;原样保留给用户(SPA 会 parse 挂徽章)。第二行 `saved: <相对路径>` 是产物路径,告诉用户。 产物自动落 `/videos/<时间戳>-.mp4` + 同名 `.meta.json`(prompt / 参数 / cost / tokens / cgt_id 全 snapshot)。 diff --git a/tests/test_seedance.py b/tests/test_seedance.py new file mode 100644 index 0000000..4dad940 --- /dev/null +++ b/tests/test_seedance.py @@ -0,0 +1,165 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch +from uuid import uuid4 + +import tools.seedance as seedance_module +from tools.seedance import SeedanceTool + + +class _FakeArkClient: + submitted_body = None + + def __init__(self, *_args, **_kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def post_json(self, _endpoint, body, **_kwargs): + type(self).submitted_body = body + return {"id": "cgt-test"} + + def get_json(self, _url, **_kwargs): + return { + "status": "succeeded", + "content": {"video_url": "https://example.com/result.mp4"}, + } + + def download(self, _url, dest, **_kwargs): + Path(dest).write_bytes(b"video") + + +class SeedanceToolTest(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) / "user" + self.root.mkdir() + _FakeArkClient.submitted_body = None + + def tearDown(self): + self._tmp.cleanup() + + def make_tool(self) -> SeedanceTool: + return SeedanceTool( + ark_cfg=object(), + video_variant_cfg={ + "model_id": "doubao-seedance-test", + "default_resolution": "720p", + "default_ratio": "16:9", + "default_duration": 5, + "default_watermark": False, + "default_generate_audio": False, + "price_cny_per_mtoken_text2video": 37.0, + "price_cny_per_mtoken_video2video": 22.0, + "fps": 24, + "poll_interval_s": 0, + }, + variant_key="seedance_test", + working_dir=self.root, + user_root=self.root, + task_id=uuid4(), + user_id=uuid4(), + ) + + def test_image_to_video_builds_first_frame_and_uses_i2v_price(self): + (self.root / "source.png").write_bytes(b"\x89PNG\r\n\x1a\n") + usage = {} + + with ( + patch.object(seedance_module, "ArkClient", _FakeArkClient), + patch.object( + seedance_module, + "record_usage_safe", + side_effect=lambda _name, _fn, **kw: usage.update(kw), + ), + ): + result = self.make_tool().execute(prompt="让水流动起来", image="source.png") + + content = _FakeArkClient.submitted_body["content"] + self.assertEqual(content[0], {"type": "text", "text": "让水流动起来"}) + self.assertEqual(content[1]["type"], "image_url") + self.assertEqual(content[1]["role"], "first_frame") + self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/png;base64,")) + self.assertIs(usage["has_video_input"], True) + self.assertEqual(usage["price_cny_per_mtoken"], 22.0) + self.assertIn("mode=image_to_video", result) + self.assertIn("image=source.png", result) + + def test_text_to_video_remains_backward_compatible(self): + usage = {} + + with ( + patch.object(seedance_module, "ArkClient", _FakeArkClient), + patch.object( + seedance_module, + "record_usage_safe", + side_effect=lambda _name, _fn, **kw: usage.update(kw), + ), + ): + result = self.make_tool().execute(prompt="水泥浆缓慢流动") + + self.assertEqual( + _FakeArkClient.submitted_body["content"], + [{"type": "text", "text": "水泥浆缓慢流动"}], + ) + self.assertIs(usage["has_video_input"], False) + self.assertEqual(usage["price_cny_per_mtoken"], 37.0) + self.assertIn("mode=text_to_video", result) + + def test_multi_image_references_use_reference_role(self): + for name in ("product-front.png", "product-side.png"): + (self.root / name).write_bytes(b"\x89PNG\r\n\x1a\n") + usage = {} + + with ( + patch.object(seedance_module, "ArkClient", _FakeArkClient), + patch.object( + seedance_module, + "record_usage_safe", + side_effect=lambda _name, _fn, **kw: usage.update(kw), + ), + ): + result = self.make_tool().execute( + prompt="参考图片1和图片2生成产品环绕展示", + reference_images=["product-front.png", "product-side.png"], + ) + + content = _FakeArkClient.submitted_body["content"] + self.assertEqual([part.get("role") for part in content[1:]], [ + "reference_image", + "reference_image", + ]) + self.assertEqual( + usage["extra_units"]["reference_images"], + ["product-front.png", "product-side.png"], + ) + self.assertIn("mode=image_to_video", result) + self.assertIn("images=2", result) + + def test_rejects_more_than_nine_images(self): + result = self.make_tool().execute( + prompt="多图参考", + reference_images=[f"image-{i}.png" for i in range(10)], + ) + + self.assertEqual( + result, + "[Error] Seedance 最多支持 9 张输入图片(image + reference_images 合计)。", + ) + + def test_rejects_image_outside_user_root(self): + outside = self.root.parent / "outside.png" + outside.write_bytes(b"\x89PNG\r\n\x1a\n") + + result = self.make_tool().execute(prompt="动起来", image=str(outside)) + + self.assertTrue(result.startswith("[Error] 图片找不到或越界:")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/seedance.py b/tools/seedance.py index d2b1060..ecc710b 100644 --- a/tools/seedance.py +++ b/tools/seedance.py @@ -1,4 +1,4 @@ -"""seedance: 调豆包 Seedance 2.0 Fast 视频生成 API,产物落 working_dir/videos/。 +"""seedance: 调豆包 Seedance 2.0 视频生成 API,支持文生/图生视频,产物落 working_dir/videos/。 异步任务: 1. POST /contents/generations/tasks → 返 `{"id": "cgt-..."}` @@ -7,7 +7,7 @@ 3. succeeded → 取 content.video_url → download 到本地 + 写 meta + 计 usage_events 模型 ID + 单价 + 默认参数全在 `config/media/doubao.yaml`,本 tool 只装配。 -计费按 token 公式 `(in_dur+out_dur) × W × H × fps / 1024`,文生视频 in_dur=0; +计费按 token 公式 `(in_dur+out_dur) × W × H × fps / 1024`; W×H 由 resolution + ratio 推算(横版 height=resolution_num,竖版 width=resolution_num)。 完成后: @@ -28,6 +28,7 @@ from core.ark_client import ArkClient, ArkConfig, ArkError from core.storage.usage import record_video_usage from .base import Tool +from .image_ref import load_image_as_data_url from .media_common import ( find_first_url, quota_gate, @@ -76,7 +77,9 @@ def _estimate_tokens(width: int, height: int, duration_s: int, fps: int, in_dur_ class SeedanceTool(Tool): name = "seedance" description = ( - "Generate a short video with Doubao Seedance 2.0 Fast and save to working_dir/videos/. " + "Generate a short video with Doubao Seedance 2.0 and save to working_dir/videos/. " + "Supports text-to-video, first-frame video via `image`, and multi-image reference video " + "via `reference_images` (up to 9 images total). " "Use only when the user explicitly asks for a video / 视频 / 动画 / 动起来. " "Async: takes 30-90s to render. Costs ~¥1.86 (480p, 5s) ~ ¥4.00 (720p, 5s); " "longer / higher-resolution scales up. Returns the saved relative path." @@ -88,6 +91,22 @@ class SeedanceTool(Tool): "type": "string", "description": "中文或英文都行,详尽描述画面 + 运动 + 镜头(主体在做什么 / 镜头怎么动 / 场景 / 风格)。", }, + "image": { + "type": "string", + "description": ( + "图生视频的首帧图片路径(可选)。支持 task_dir 相对路径、用户上传图片路径," + "或工具上次返回的 saved 路径;不传则不固定首帧。" + ), + }, + "reference_images": { + "type": "array", + "description": ( + "多图参考路径列表(可选),用于保持人物、产品、场景或风格一致;" + "每项支持与 image 相同的路径形态。与 image 合计最多 9 张。" + ), + "items": {"type": "string"}, + "maxItems": 9, + }, "resolution": { "type": "string", "description": "Video resolution. fast 版仅支持 '480p' / '720p'(默认 720p);1080p+ 仅 pro 可用。", @@ -154,6 +173,8 @@ class SeedanceTool(Tool): def execute( self, prompt: str, + image: Optional[str] = None, + reference_images: Optional[list[str]] = None, resolution: Optional[str] = None, ratio: Optional[str] = None, duration: Optional[int] = None, @@ -164,6 +185,43 @@ class SeedanceTool(Tool): if not (prompt or "").strip(): return "[Error] prompt 不能为空" resume_id = (resume_task_id or "").strip() + image_arg = (image or "").strip() + reference_args = [str(v).strip() for v in (reference_images or []) if str(v).strip()] + max_images = int(self.cfg.get("max_reference_images", 9)) + if len(reference_args) + bool(image_arg) > max_images: + return f"[Error] Seedance 最多支持 {max_images} 张输入图片(image + reference_images 合计)。" + + max_image_bytes = int(float(self.cfg.get("max_image_mb", 10)) * 1024 * 1024) + max_total_bytes = int(float(self.cfg.get("max_total_image_mb", 30)) * 1024 * 1024) + loaded_images: list[tuple[str, str, str]] = [] + image_specs = ([("first_frame", image_arg)] if image_arg else []) + [ + ("reference_image", path) for path in reference_args + ] + total_image_bytes = 0 + for role, path in image_specs: + data_url, display, image_err = load_image_as_data_url( + path, + working_dir=self.working_dir, + user_root=self.user_root, + display_fn=self._display, + max_bytes=max_image_bytes, + ) + if image_err: + return image_err + total_image_bytes += len(data_url.rsplit(",", 1)[-1]) * 3 // 4 + if total_image_bytes > max_total_bytes: + return ( + f"[Error] 输入图片合计超过 {max_total_bytes // 1024 // 1024}MB 上限。" + "请减少图片数量或先压缩图片。" + ) + loaded_images.append((role, data_url, display)) + has_image_input = bool(loaded_images) + first_frame_display = next( + (display for role, _url, display in loaded_images if role == "first_frame"), "" + ) + reference_displays = [ + display for role, _url, display in loaded_images if role == "reference_image" + ] # 每账号每日配额(yaml quotas.videos_per_day,细节见 media_common.quota_gate)。 # resume 不过配额闸:原次提交已经占过额度,续查不是新生成。 @@ -189,13 +247,23 @@ class SeedanceTool(Tool): poll_interval = float(cfg.get("poll_interval_s", 5)) poll_timeout = float(cfg.get("poll_timeout_s", 600)) price_t2v = float(cfg.get("price_cny_per_mtoken_text2video", 37.0)) + price_i2v = float(cfg.get("price_cny_per_mtoken_video2video", price_t2v)) + chosen_price = price_i2v if has_image_input else price_t2v submit_endpoint = cfg.get("endpoint_submit", "/contents/generations/tasks") poll_endpoint_base = cfg.get("endpoint_poll", "/contents/generations/tasks") + content: list[dict[str, Any]] = [{"type": "text", "text": prompt}] + for role, data_url, _display in loaded_images: + content.append({ + "type": "image_url", + "image_url": {"url": data_url}, + "role": role, + }) + body: dict[str, Any] = { "model": model_id, - "content": [{"type": "text", "text": prompt}], + "content": content, "ratio": chosen_ratio, "resolution": chosen_resolution, "duration": chosen_duration, @@ -226,7 +294,7 @@ class SeedanceTool(Tool): return ( f"[Cancelled] seedance task {cgt_id} 等待被中断(远端任务仍在跑," f"24h 内可用 resume_task_id=\"{cgt_id}\" 继续等待/取回结果,不重复计费;" - f"Volcengine 失败/成功才计费,若仍出片可能产生 ~¥{self._rough_cost(chosen_resolution, chosen_ratio, chosen_duration, fps, price_t2v):.2f})" + f"Volcengine 失败/成功才计费,若仍出片可能产生 ~¥{self._rough_cost(chosen_resolution, chosen_ratio, chosen_duration, fps, chosen_price):.2f})" ) if time.monotonic() > deadline: return ( @@ -262,10 +330,13 @@ class SeedanceTool(Tool): width, height = _resolve_dimensions(chosen_resolution, chosen_ratio) tokens_estimated = _estimate_tokens(width, height, chosen_duration, fps, in_dur_s=0) tokens_actual = self._extract_tokens(final_resp) or tokens_estimated - cost_cny = tokens_actual * price_t2v / 1_000_000.0 + cost_cny = tokens_actual * chosen_price / 1_000_000.0 meta = { "prompt": prompt, + "mode": "image_to_video" if has_image_input else "text_to_video", + "input_image": first_frame_display or None, + "reference_images": reference_displays, "model_id": model_id, "resolution": chosen_resolution, "ratio": chosen_ratio, @@ -277,7 +348,7 @@ class SeedanceTool(Tool): "generate_audio": chosen_generate_audio, "tokens": tokens_actual, "tokens_estimated": tokens_estimated, - "price_cny_per_mtoken": price_t2v, + "price_cny_per_mtoken": chosen_price, "cost_cny": round(cost_cny, 4), "elapsed_s": round(elapsed, 1), "cgt_id": cgt_id, @@ -297,21 +368,29 @@ class SeedanceTool(Tool): width=width, height=height, tokens=tokens_actual, - price_cny_per_mtoken=price_t2v, - has_video_input=False, # phase 1 仅 t2v;i2v 接入后这里读 body 判断 + price_cny_per_mtoken=chosen_price, + # usage schema 沿用历史字段名;其语义已包含图片/视频类视觉输入。 + has_video_input=has_image_input, watermark=chosen_watermark, extra_units={ "cgt_id": cgt_id, "elapsed_s": round(elapsed, 1), "generate_audio": chosen_generate_audio, + "input_image": first_frame_display or None, + "reference_images": reference_displays, }, ) disp = self._display(dest_mp4) + mode = "image_to_video" if has_image_input else "text_to_video" + image_banner = f" · images={len(loaded_images)}" if loaded_images else "" + if first_frame_display: + image_banner += f" · image={first_frame_display}" # banner 协议与 seedream 一致:首行 `[tool] key=value · key=value ...` # 前端 extractMediaBanner 已 whitelist seedance,正则抓 key=value 挂徽章 return ( - f"[seedance] model={model_id} · resolution={chosen_resolution} · ratio={chosen_ratio} · " + f"[seedance] model={model_id} · mode={mode}{image_banner} · " + f"resolution={chosen_resolution} · ratio={chosen_ratio} · " f"duration={chosen_duration}s · audio={chosen_generate_audio} · " f"cost=¥{cost_cny:.2f} · elapsed={elapsed:.1f}s\n" f"saved: {disp}\n"