@clawhub-deemo-soul-8279b3801c
使用剪映(Jianying/小云雀)的 Seedance 2.0 模型自动生成AI视频。支持文生视频(T2V)、图生视频(I2V)和参考视频生成(V2V)三种模式。当用户需要生成AI视频、使用Seedance模型创作短片、或基于参考图像/视频进行风格转换时使用此技能。需要预先配置 cookies.json 登录凭证。
---
name: jianying-video-gen
description: 使用剪映(Jianying/小云雀)的 Seedance 2.0 模型自动生成AI视频。支持文生视频(T2V)、图生视频(I2V)和参考视频生成(V2V)三种模式。当用户需要生成AI视频、使用Seedance模型创作短片、或基于参考图像/视频进行风格转换时使用此技能。需要预先配置 cookies.json 登录凭证。
---
# 剪映 AI 视频生成器
通过 Playwright 自动化操作剪映(xyq.jianying.com),使用 Seedance 2.0 模型生成 AI 视频。
## 前置条件
1. **Python 3.9+** + `playwright` 已安装
2. **Chromium** 已通过 `playwright install chromium` 安装
3. **cookies.json** — 剪映登录凭证(从浏览器导出),放在脚本同目录下
```bash
pip install playwright && playwright install chromium
```
## 核心脚本
`scripts/jianying_worker.py` — 主自动化脚本
## 使用方式
### 文生视频 (T2V)
```bash
python3 scripts/jianying_worker.py \
--cookies /path/to/cookies.json \
--output-dir /path/to/output \
--prompt "赛博朋克风格的长安城,飞行汽车穿梭在霓虹灯笼之间" \
--duration 10s \
--model "Seedance 2.0"
```
### 图生视频 (I2V)
```bash
python3 scripts/jianying_worker.py \
--cookies /path/to/cookies.json \
--output-dir /path/to/output \
--ref-image /path/to/image.png \
--prompt "将这张图片变成动画,镜头从左向右缓慢平移" \
--duration 10s \
--model "Seedance 2.0 Fast"
```
### 参考视频生成 (V2V)
```bash
python3 scripts/jianying_worker.py \
--cookies /path/to/cookies.json \
--output-dir /path/to/output \
--ref-video /path/to/reference.mp4 \
--prompt "画风改成宫崎骏风格,其他不变" \
--duration 10s \
--model "Seedance 2.0"
```
### Dry-Run 模式(调试用)
```bash
python3 scripts/jianying_worker.py --cookies /path/to/cookies.json --prompt "测试" --dry-run
```
> 只填写表单不提交,生成 `step_*.png` 截图供检查。
## 参数说明
| 参数 | 默认值 | 可选值 | 说明 |
|------|--------|--------|------|
| `--prompt` | "一个美女在跳舞" | 任意文本 | 视频描述 |
| `--duration` | `10s` | `5s`, `10s`, `15s` | 视频时长 |
| `--ratio` | `横屏` | `横屏`, `竖屏`, `方屏` | 画面比例 |
| `--model` | `Seedance 2.0` | `Seedance 2.0`, `Seedance 2.0 Fast` | 模型选择 |
| `--ref-image` | 无 | 本地图片路径 | I2V 模式的参考图片 |
| `--ref-video` | 无 | 本地视频路径 | V2V 模式的参考视频 |
| `--cookies` | `cookies.json` | 文件路径 | 剪映登录凭证路径 |
| `--output-dir` | `.` | 目录路径 | 输出视频保存目录 |
| `--dry-run` | false | - | 只填表不提交 |
## 模型与积分
| 模型 | 积分/秒 | 5s | 10s | 15s | 特点 |
|------|---------|-----|------|------|------|
| Seedance 2.0 Fast | 3 | 15 | 30 | 45 | 快速,适合测试 |
| Seedance 2.0 | 5 | 25 | 50 | 75 | 高质量,正式出片 |
## 自动化流程
```
登录(cookies) → 新建 → 沉浸式短片 → 选模型 → [上传参考视频] → 选时长 → 输入Prompt → 提交
→ 拦截 thread_id → 导航详情页 → 轮询视频 → curl 下载 MP4
```
## 提示词编写指南
详细的提示词示例和编写技巧参见 `references/prompt-guide.md`。
## 常见问题
**Q: cookies 过期了怎么办?**
在浏览器登录 xyq.jianying.com,使用 EditThisCookie 等扩展导出 cookies.json。
**Q: 下载 403?**
脚本使用 thread_id 详情页 + curl 下载,CDN 链接无需 cookie。如仍失败检查网络。
**Q: 上传参考视频很慢?**
正常,8MB 视频约需 60-90 秒。脚本会自动等待最多 5 分钟。
FILE:cookies.json
[ { "domain": ".xyq.jianying.com", "expirationDate": 1777706712.979184, "hostOnly": false, "httpOnly": true, "name": "uid_tt_ss_pippitcn_web", "path": "/", "sameSite": "no_restriction", "secure": true, "session": false, "storeId": null, "value": "83a889761fcccc4537e5df005e8da1a1" }, { "domain": ".xyq.jianying.com", "expirationDate": 1777706712.97926, "hostOnly": false, "httpOnly": true, "name": "ssid_ucp_v1_pippitcn_web", "path": "/", "sameSite": "no_restriction", "secure": true, "session": false, "storeId": null, "value": "1.0.0-KGRjNjU5MjVlZjA4ODY5MGM1NTJmZWQ1ODY0NWFhMTRiNDA1YmI4MDgKFwiLnZDGpq0OENiZms0GGP_HMDgCQOwHGgJsZiIgYmE0YTA1Y2M4OWI5Y2RiZjVkNjJkZGU0NzIxYTgxYzI" }, { "domain": "xyq.jianying.com", "expirationDate": 1772781888, "hostOnly": true, "httpOnly": false, "name": "gfkadpd", "path": "/", "sameSite": "no_restriction", "secure": true, "session": false, "storeId": null, "value": "795647,44487" }, { "domain": ".xyq.jianying.com", "expirationDate": 1775114712.979154, "hostOnly": false, "httpOnly": true, "name": "passport_auth_status_ss_pippitcn_web", "path": "/", "sameSite": "no_restriction", "secure": true, "session": false, "storeId": null, "value": "f08b90a93e60f7dce1528c862af8b4ac%2C" }, { "domain": ".jianying.com", "expirationDate": 1777706695.880716, "hostOnly": false, "httpOnly": false, "name": "passport_csrf_token", "path": "/", "sameSite": "no_restriction", "secure": true, "session": false, "storeId": null, "value": "00d77f8a649cddc032434d34158fdbc2" }, { "domain": ".xyq.jianying.com", "expirationDate": 1777706712.979227, "hostOnly": false, "httpOnly": true, "name": "session_tlb_tag_pippitcn_web", "path": "/", "sameSite": "no_restriction", "secure": true, "session": false, "storeId": null, "value": "sttt%7C1%7CukoFzIm5zb9dYt3kchqBwv_________HEMIT_anWEkHA2KKhouxaqt8mwRQc9rSR-VRaGlxoNZg%3D" }, { "domain": ".xyq.jianying.com", "expirationDate": 1777706712.979216, "hostOnly": false, "httpOnly": true, "name": "sessionid_ss_pippitcn_web", "path": "/", "sameSite": "no_restriction", "secure": true, "session": false, "storeId": null, "value": "ba4a05cc89b9cdbf5d62dde4721a81c2" }, { "domain": ".xyq.jianying.com", "expirationDate": 1777706712.979248, "hostOnly": false, "httpOnly": true, "name": "sid_ucp_v1_pippitcn_web", "path": "/", "sameSite": null, "secure": true, "session": false, "storeId": null, "value": "1.0.0-KGRjNjU5MjVlZjA4ODY5MGM1NTJmZWQ1ODY0NWFhMTRiNDA1YmI4MDgKFwiLnZDGpq0OENiZms0GGP_HMDgCQOwHGgJsZiIgYmE0YTA1Y2M4OWI5Y2RiZjVkNjJkZGU0NzIxYTgxYzI" } ]
FILE:mcp_server.py
#!/usr/bin/env python3
"""
MCP Server for Jianying Video Generation
封装剪映 Seedance 2.0 视频生成功能
"""
import asyncio
import json
import sys
import os
from pathlib import Path
# 添加脚本目录到路径
SCRIPT_DIR = Path(__file__).parent / "scripts"
sys.path.insert(0, str(SCRIPT_DIR))
# MCP 协议常量
JSONRPC_VERSION = "2.0"
class JianyingMCPServer:
def __init__(self):
self.server_info = {
"name": "jianying-video-gen",
"version": "1.0.0"
}
def send_message(self, message: dict):
"""发送 JSON-RPC 消息"""
json_str = json.dumps(message, ensure_ascii=False)
print(json_str, flush=True)
def send_error(self, id, code: int, message: str):
"""发送错误响应"""
self.send_message({
"jsonrpc": JSONRPC_VERSION,
"id": id,
"error": {
"code": code,
"message": message
}
})
def send_result(self, id, result: dict):
"""发送成功响应"""
self.send_message({
"jsonrpc": JSONRPC_VERSION,
"id": id,
"result": result
})
def handle_initialize(self, id: int, params: dict):
"""处理初始化请求"""
self.send_result(id, {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": self.server_info
})
def handle_tools_list(self, id: int):
"""返回可用工具列表"""
tools = [
{
"name": "text_to_video",
"description": "文生视频:根据文本描述生成AI视频",
"inputSchema": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "视频描述提示词"
},
"duration": {
"type": "string",
"enum": ["5s", "10s", "15s"],
"default": "10s",
"description": "视频时长"
},
"ratio": {
"type": "string",
"enum": ["横屏", "竖屏", "方屏"],
"default": "横屏",
"description": "画面比例"
},
"model": {
"type": "string",
"enum": ["Seedance 2.0", "Seedance 2.0 Fast"],
"default": "Seedance 2.0",
"description": "模型选择"
},
"output_dir": {
"type": "string",
"default": ".",
"description": "输出目录"
}
},
"required": ["prompt"]
}
},
{
"name": "image_to_video",
"description": "图生视频:根据参考图片生成动画视频",
"inputSchema": {
"type": "object",
"properties": {
"image_path": {
"type": "string",
"description": "参考图片路径"
},
"prompt": {
"type": "string",
"description": "动画描述提示词"
},
"duration": {
"type": "string",
"enum": ["5s", "10s", "15s"],
"default": "10s"
},
"ratio": {
"type": "string",
"enum": ["横屏", "竖屏", "方屏"],
"default": "横屏"
},
"model": {
"type": "string",
"enum": ["Seedance 2.0", "Seedance 2.0 Fast"],
"default": "Seedance 2.0"
},
"output_dir": {
"type": "string",
"default": "."
}
},
"required": ["image_path", "prompt"]
}
},
{
"name": "video_to_video",
"description": "参考视频生成:基于参考视频进行风格转换",
"inputSchema": {
"type": "object",
"properties": {
"video_path": {
"type": "string",
"description": "参考视频路径"
},
"prompt": {
"type": "string",
"description": "风格转换提示词"
},
"duration": {
"type": "string",
"enum": ["5s", "10s", "15s"],
"default": "10s"
},
"ratio": {
"type": "string",
"enum": ["横屏", "竖屏", "方屏"],
"default": "横屏"
},
"model": {
"type": "string",
"enum": ["Seedance 2.0", "Seedance 2.0 Fast"],
"default": "Seedance 2.0"
},
"output_dir": {
"type": "string",
"default": "."
}
},
"required": ["video_path", "prompt"]
}
},
{
"name": "get_credits_info",
"description": "获取积分消耗信息",
"inputSchema": {
"type": "object",
"properties": {}
}
}
]
self.send_result(id, {"tools": tools})
async def handle_tool_call(self, id: int, name: str, arguments: dict):
"""处理工具调用"""
skill_dir = Path(__file__).parent
cookies_path = skill_dir / "cookies.json"
if not cookies_path.exists():
self.send_error(id, -32600, f"未找到 cookies.json,请先配置登录凭证: {cookies_path}")
return
if name == "get_credits_info":
self.send_result(id, {
"content": [{
"type": "text",
"text": """## 剪映 Seedance 2.0 积分消耗
| 模型 | 5秒 | 10秒 | 15秒 |
|------|-----|------|------|
| Seedance 2.0 Fast | 15积分 | 30积分 | 45积分 |
| Seedance 2.0 | 25积分 | 50积分 | 75积分 |
请确保账户有足够积分后再生成视频。"""
}]
})
return
# 构建命令参数
cmd = ["python", str(skill_dir / "scripts" / "jianying_worker.py")]
cmd.extend(["--cookies", str(cookies_path)])
# 确保输出目录存在
default_output = r"D:\SQLMessage\AI_Videos"
output_dir = arguments.get("output_dir", default_output)
os.makedirs(output_dir, exist_ok=True)
cmd.extend(["--output-dir", output_dir])
if name == "text_to_video":
cmd.extend([
"--prompt", arguments["prompt"],
"--duration", arguments.get("duration", "10s"),
"--ratio", arguments.get("ratio", "横屏"),
"--model", arguments.get("model", "Seedance 2.0")
])
elif name == "image_to_video":
cmd.extend([
"--ref-image", arguments["image_path"],
"--prompt", arguments["prompt"],
"--duration", arguments.get("duration", "10s"),
"--ratio", arguments.get("ratio", "横屏"),
"--model", arguments.get("model", "Seedance 2.0")
])
elif name == "video_to_video":
cmd.extend([
"--ref-video", arguments["video_path"],
"--prompt", arguments["prompt"],
"--duration", arguments.get("duration", "10s"),
"--ratio", arguments.get("ratio", "横屏"),
"--model", arguments.get("model", "Seedance 2.0")
])
else:
self.send_error(id, -32601, f"未知工具: {name}")
return
# 执行命令
try:
import subprocess
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300, # 5分钟超时
cwd=str(skill_dir)
)
stdout = result.stdout
stderr = result.stderr
# 解析输出,查找视频下载信息
video_path = None
for line in stdout.split('\n'):
if '下载完成' in line or '保存至' in line:
video_path = line.strip()
break
if result.returncode == 0:
response_text = f"""✅ 视频生成任务已提交
**提示词**: {arguments.get('prompt')}
**时长**: {arguments.get('duration', '10s')}
**模型**: {arguments.get('model', 'Seedance 2.0')}
{video_path if video_path else '视频已生成,请检查输出目录'}
**输出目录**: {os.path.abspath(output_dir)}
**标准输出**:
```
{stdout[-1000:] if len(stdout) > 1000 else stdout}
```"""
self.send_result(id, {
"content": [{
"type": "text",
"text": response_text
}]
})
else:
self.send_error(id, -32603, f"视频生成失败:\n{stderr}\n{stdout}")
except subprocess.TimeoutExpired:
self.send_error(id, -32603, "视频生成超时(超过5分钟)")
except Exception as e:
self.send_error(id, -32603, f"执行错误: {str(e)}")
async def run(self):
"""主循环"""
while True:
try:
line = input()
if not line:
continue
message = json.loads(line)
method = message.get("method")
msg_id = message.get("id")
params = message.get("params", {})
if method == "initialize":
self.handle_initialize(msg_id, params)
elif method == "tools/list":
self.handle_tools_list(msg_id)
elif method == "tools/call":
name = params.get("name")
arguments = params.get("arguments", {})
await self.handle_tool_call(msg_id, name, arguments)
elif method == "notifications/initialized":
# 初始化完成通知,无需响应
pass
else:
self.send_error(msg_id, -32601, f"未知方法: {method}")
except json.JSONDecodeError as e:
self.send_error(None, -32700, f"JSON解析错误: {str(e)}")
except EOFError:
break
except Exception as e:
self.send_error(None, -32603, f"内部错误: {str(e)}")
if __name__ == "__main__":
server = JianyingMCPServer()
asyncio.run(server.run())
FILE:references/prompt-guide.md
# Seedance 2.0 提示词编写指南
## 核心原则
Seedance 2.0 对中文提示词有很好的理解。写提示词时注重:**主体 + 动作 + 风格 + 镜头 + 环境**。
## 提示词模板
```
[主体描述],[动作/行为],[画面风格],[镜头语言],[环境/光影]
```
## 经典示例
### 🎬 运动 & 动作
```
一位武术大师在雨中练太极,慢动作,水滴飞溅,电影级光影
三个街舞少年在霓虹灯下的篮球场battle,地面反光,烟雾弥漫
```
### 🌊 自然 & 特效
```
一朵玫瑰从花苞到盛开的延时摄影,露珠滚落花瓣,背景虚化,4K微距
巨龙从火山口腾空而起,熔岩四溅,暴风雨中闪电照亮整个天空,电影级航拍
```
### 🎥 镜头语言
```
镜头穿越东京涩谷十字路口的人群,从地面缓慢上升到鸟瞰视角,夜景霓虹灯,电影质感
一颗眼泪从眼角滑落的极致特写,然后镜头拉远,露出一个站在雨中的女孩全身
```
### 🔥 风格化
```
赛博朋克风格的长安城,飞行汽车穿梭在霓虹灯笼之间,古代宫殿与全息投影交融,雾气弥漫的街道
中国水墨画风格,仙女在云端起舞,飘逸的白色长裙随风飘动,山水画背景,仙鹤环绕,月光如瀑
```
### 🏯 超现实
```
一座中国古城从海底缓缓升起,鲸鱼在宫殿之间游过,阳光穿透海水照亮金色琉璃瓦,水下摄影
一杯咖啡慢动作倾倒,液体在空中形成完美的抛物线,溅落在白色大理石台面上,每一滴都清晰可见
```
## V2V 参考视频提示词
参考视频模式的提示词重点是**描述风格变换**,保留原视频的动作和构图:
```
参考视频,画风改成宫崎骏风格的画风,其他不变
赛博朋克风格,霓虹灯光照射下的机械舞者,金属质感皮肤,全息投影背景
三只穿着华丽晚礼服的猫咪在舞会上优雅起舞,迪士尼动画风格,水晶吊灯,金色大厅
```
## 积分优化策略
- **测试阶段**: 用 `--model "Seedance 2.0 Fast" --duration 5s`(15积分/次)
- **正式出片**: 用 `--model "Seedance 2.0" --duration 10s`(50积分/次)
FILE:requirements.txt
playwright>=1.42.0
requests>=2.31.0
FILE:scripts/download_video.py
import asyncio
import json
import os
import re
import argparse
from playwright.async_api import async_playwright
COOKIES_FILE = os.path.join(os.path.dirname(__file__), '..', 'cookies.json')
DOWNLOAD_DIR = r'D:\SQLMessage\AI_Videos'
def load_and_clean_cookies():
with open(COOKIES_FILE, 'r') as f:
raw = json.load(f)
cleaned = []
allowed = ['name', 'value', 'domain', 'path', 'expires', 'httpOnly', 'secure']
for c in raw:
clean = {}
for key in allowed:
if key == 'expires':
val = c.get('expirationDate') or c.get('expires')
if val is not None:
clean['expires'] = val
continue
if key in c and c[key] is not None:
clean[key] = c[key]
cleaned.append(clean)
return cleaned
async def download_with_page(context, video_url, output_path):
"""使用新页面直接访问视频URL来下载"""
print(f" [下载] 使用Playwright页面下载...")
# 创建新页面访问视频URL
video_page = await context.new_page()
try:
# 导航到视频URL - 不等待networkidle,因为大视频不会触发
print(f" [信息] 请求视频...")
response = await video_page.goto(video_url, wait_until='domcontentloaded', timeout=120000)
if response:
print(f" [信息] 响应状态: {response.status}")
print(f" [信息] Content-Type: {response.headers.get('content-type', 'unknown')}")
print(f" [信息] Content-Length: {response.headers.get('content-length', 'unknown')}")
if response.status in [200, 206]:
# 等待一下确保数据开始传输
await asyncio.sleep(2)
# 直接读取响应体
print(f" [信息] 读取响应体...")
body = await response.body()
if body and len(body) > 1000: # 确保不是空响应或错误页面
with open(output_path, 'wb') as f:
f.write(body)
size = os.path.getsize(output_path)
print(f" [OK] 下载成功: {size/1024/1024:.2f} MB")
await video_page.close()
return True
else:
print(f" [ERR] 响应体太小: {len(body) if body else 0} bytes")
print(f" [ERR] 无法下载,状态: {response.status if response else 'None'}")
await video_page.close()
return False
except Exception as e:
print(f" [ERR] 下载异常: {e}")
import traceback
traceback.print_exc()
try:
await video_page.close()
except:
pass
return False
async def query_and_download(task_id: str, output_name: str = None):
"""查询任务并下载视频"""
print(f"[查询] 任务ID: {task_id}")
# 确保输出目录存在
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=['--no-sandbox', '--disable-setuid-sandbox']
)
context = await browser.new_context(
viewport={'width': 1920, 'height': 1080}
)
# 注入cookies
print("[步骤1] 注入登录凭证...")
cookies = load_and_clean_cookies()
await context.add_cookies(cookies)
print(f" [OK] {len(cookies)} cookies 已注入")
page = await context.new_page()
# 导航到任务详情页
detail_url = f"https://xyq.jianying.com/home?tab_name=integrated-agent&thread_id={task_id}"
print(f"[步骤2] 访问任务详情页...")
print(f" {detail_url}")
try:
await page.goto(detail_url, wait_until='networkidle', timeout=30000)
await asyncio.sleep(3)
except Exception as e:
print(f" [WARN] 页面加载超时,继续检查...")
# 检查视频是否就绪
print("[步骤3] 检查视频状态...")
# 滚动页面确保视频元素加载
await page.evaluate('window.scrollTo(0, document.body.scrollHeight)')
await asyncio.sleep(1)
# 尝试多种方式找到视频元素
video_url = None
# 方法1: 直接查询 video 标签
try:
video_element = await page.query_selector('video')
if video_element:
video_url = await video_element.get_attribute('src')
if video_url:
print(f" [OK] 从video标签找到视频")
except:
pass
# 方法2: 查询 source 标签
if not video_url:
try:
source_element = await page.query_selector('video source')
if source_element:
video_url = await source_element.get_attribute('src')
if video_url:
print(f" [OK] 从source标签找到视频")
except:
pass
# 方法3: 从页面HTML中提取
if not video_url:
content = await page.content()
patterns = [
r'https?://[^\s"\'<>]+\.mp4[^\s"\'<>]*',
r'"url"\s*:\s*"(https?://[^"]+video[^"]*)"',
r'src="(https?://[^"]+\.mp4[^"]*)"',
]
for pattern in patterns:
matches = re.findall(pattern, content)
if matches:
video_url = matches[0]
print(f" [OK] 从HTML提取到视频链接")
break
# 检查状态
if not video_url:
page_text = await page.evaluate('() => document.body.innerText')
if '生成中' in page_text:
print(" [状态] 视频仍在生成中,请稍后再试")
await browser.close()
return False
elif '失败' in page_text:
print(" [状态] 视频生成失败")
await browser.close()
return False
else:
print(" [ERR] 未找到视频链接")
debug_path = os.path.join(DOWNLOAD_DIR, f'debug_{task_id}.png')
await page.screenshot(path=debug_path, full_page=True)
print(f" [调试] 截图已保存: {debug_path}")
await browser.close()
return False
# 清理URL
video_url = video_url.replace('\\u0026', '&').replace('\\', '')
print(f" [链接] {video_url[:80]}...")
# 下载视频
print("[步骤4] 开始下载视频...")
if not output_name:
output_name = f"video_{task_id[:8]}.mp4"
if not output_name.endswith('.mp4'):
output_name += '.mp4'
output_path = os.path.join(DOWNLOAD_DIR, output_name)
success = await download_with_page(context, video_url, output_path)
await browser.close()
if success:
print(f"\n[完成] 视频已保存: {output_path}")
return True
return False
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='查询并下载剪映视频')
parser.add_argument('task_id', help='任务ID')
parser.add_argument('--output', '-o', help='输出文件名(可选)')
args = parser.parse_args()
asyncio.run(query_and_download(args.task_id, args.output))
FILE:scripts/jianying_worker.py
"""
小云雀 (Jianying) 自动化视频生成 v5
引擎: Playwright + Chromium
支持: 文生视频 (T2V) + 参考视频生成 (V2V)
"""
import asyncio
import json
import re
import os
import html
import argparse
from playwright.async_api import async_playwright
COOKIES_FILE = 'cookies.json' # 可通过 --cookies 覆盖
DOWNLOAD_DIR = '.' # 可通过 --output-dir 覆盖
def load_and_clean_cookies():
with open(COOKIES_FILE, 'r') as f:
raw = json.load(f)
cleaned = []
allowed = ['name', 'value', 'domain', 'path', 'expires', 'httpOnly', 'secure']
for c in raw:
clean = {}
for key in allowed:
if key == 'expires':
val = c.get('expirationDate') or c.get('expires')
if val is not None:
clean['expires'] = val
continue
if key in c and c[key] is not None:
clean[key] = c[key]
cleaned.append(clean)
return cleaned
DEBUG_SCREENSHOTS = False # 由 --dry-run 控制
async def screenshot(page, name):
if not DEBUG_SCREENSHOTS:
return
path = os.path.join(DOWNLOAD_DIR, f'step_{name}.png')
await page.screenshot(path=path)
print(f" [截图] Screenshot: {path}")
async def safe_click(page, locator_or_selector, label, timeout=5000):
"""用 Playwright locator.click() 点击元素模拟真实鼠标事件"""
try:
if isinstance(locator_or_selector, str):
loc = page.locator(locator_or_selector).first
else:
loc = locator_or_selector
await loc.click(timeout=timeout)
print(f" [OK] {label}: clicked")
return True
except Exception as e:
print(f" [ERR] {label}: {e}")
return False
async def run(prompt: str, duration: str = "10s", ratio: str = "横屏", model: str = "Seedance 2.0", dry_run: bool = False, ref_video: str = None, ref_image: str = None):
global DEBUG_SCREENSHOTS
DEBUG_SCREENSHOTS = dry_run
if ref_image:
mode_label = "I2V (图生视频)"
elif ref_video:
mode_label = "V2V (参考视频)"
else:
mode_label = "T2V (文生视频)"
print(f"[启动] Starting Playwright + Chromium (headless)... [{mode_label}]")
if ref_video and not os.path.exists(ref_video):
print(f"[错误] 参考视频文件不存在: {ref_video}")
return
if ref_image and not os.path.exists(ref_image):
print(f"[错误] 参考图片文件不存在: {ref_image}")
return
if ref_video:
size_mb = os.path.getsize(ref_video) / (1024 * 1024)
print(f"[附件] 参考视频: {ref_video} ({size_mb:.1f}MB)")
if ref_image:
size_kb = os.path.getsize(ref_image) / 1024
print(f"[图片] 参考图片: {ref_image} ({size_kb:.0f}KB)")
if dry_run:
print("[警告] DRY-RUN MODE: will fill form but NOT click '开始创作'")
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=['--no-sandbox', '--disable-setuid-sandbox']
)
context = await browser.new_context(
viewport={'width': 1920, 'height': 1080}
)
# === Step 1: Cookie 注入 ===
print("[步骤1] [Step 1] Injecting cookies...")
cookies = load_and_clean_cookies()
await context.add_cookies(cookies)
print(f" [OK] {len(cookies)} cookies injected")
page = await context.new_page()
# === Step 2: 导航 ===
print("[步骤2] [Step 2] Navigating to xyq.jianying.com/home...")
await page.goto('https://xyq.jianying.com/home', wait_until='domcontentloaded')
await page.wait_for_timeout(8000)
await screenshot(page, '2_loaded')
# === Step 3: 登录验证 ===
print("[步骤3] [Step 3] Checking login status...")
content = await page.content()
is_logged_in = '开始创作' in content or '登录' not in content
if is_logged_in:
print(" [OK] LOGIN_SUCCESS")
else:
print(" [ERR] LOGIN_FAILED 请重新导出 cookies.json")
await browser.close()
return
# === Step 3.5: 点击 "+ 新建" ===
# 使用 Playwright locator 精确匹配左上角的按钮
print("[步骤3.5] [Step 3.5] Clicking '+ 新建'...")
await safe_click(page, page.locator('text=新建').first, '新建')
await page.wait_for_timeout(3000)
await screenshot(page, '3_5_new_page')
# === Step 3.6: 上传参考图片 (仅 I2V 模式) ===
if ref_image:
print(f"[图片] [Step 3.6] Uploading reference image: {os.path.basename(ref_image)}")
# 点击输入区域的 "+" 按钮 (工具栏最左边)
# 点击输入区域的 "+" 按钮 (包含 lucide-plus SVG在 "模式" 左侧)
# DOM 结构显示它和 "模式" 都在 .configButtons 容器内
plus_clicked = False
try:
# 寻找包含 "模式" 文本最近的祖先且内部有 lucide-plus 图标的按钮
plus_locator = page.locator('div:has(> div > span:text("模式"))').locator('..').locator('button:has(svg.lucide-plus), div[class*="uploadContainer"] button').first
box = await plus_locator.bounding_box()
if not box:
# 备用方案全局找 lucide-plus 但在 toolbar 区域内
plus_locator = page.locator('button:has(svg.lucide-plus)').first
await plus_locator.click(timeout=3000)
plus_clicked = True
print(f" + 按钮: OK (Playwright locator)")
except Exception as e:
print(f" + 按钮: locator_fail ({e})")
if not plus_clicked:
# 最后的 evaluate 兜底方案
plus_result = await page.evaluate('''() => {
const svgs = Array.from(document.querySelectorAll('svg.lucide-plus'));
const targetSvg = svgs.find(svg => {
const r = svg.getBoundingClientRect();
return r.top > 300 && r.top < 600 && r.left > 400 && r.left < 800;
});
if (targetSvg) {
const btn = targetSvg.closest('button') || targetSvg.parentElement;
btn.click();
return 'OK_EVAL (svg.lucide-plus found)';
}
return 'NOT_FOUND';
}''')
print(f" + 按钮: eval fallback -> {plus_result}")
plus_clicked = plus_result.startswith('OK')
await page.wait_for_timeout(2000)
await screenshot(page, '3_6_plus_menu')
if plus_clicked:
# 点击 "本地上传" 并上传图片
try:
async with page.expect_file_chooser(timeout=10000) as fc_info:
upload_clicked = await page.evaluate('''() => {
const all = Array.from(document.querySelectorAll('*'));
const candidates = all.filter(el => {
const text = el.innerText && el.innerText.trim();
if (!text) return false;
return text === '本地上传' || text === '从本地上传';
});
candidates.sort((a, b) => {
return (a.offsetWidth * a.offsetHeight) - (b.offsetWidth * b.offsetHeight);
});
if (candidates.length > 0) {
const el = candidates[0];
el.click();
return 'OK: ' + el.tagName;
}
return 'NOT_FOUND';
}''')
print(f" 本地上传: {upload_clicked}")
if upload_clicked == 'NOT_FOUND':
raise Exception("'本地上传' not found in menu")
file_chooser = await fc_info.value
await file_chooser.set_files(ref_image)
print(f" [OK] 图片已选择: {os.path.basename(ref_image)}")
# 等待图片上传完成 (检测缩略图出现)
print(" [等待] 等待图片上传...")
for wait_i in range(30):
await page.wait_for_timeout(3000)
has_image = await page.evaluate('''() => {
const container = document.querySelector('div[class*="inputContainer"]');
if (!container) return false;
return container.querySelector('img') !== null;
}''')
if has_image:
print(f" [OK] 图片上传完成 (elapsed: {(wait_i+1)*3}s)")
break
if wait_i > 0 and wait_i % 5 == 0:
print(f" [等待] 等待中... ({(wait_i+1)*3}s)")
# 点击空白处关闭任何弹出的菜单
await page.mouse.click(0, 0)
except Exception as e:
print(f" [ERR] 图片上传失败: {e}")
await page.wait_for_timeout(2000)
await screenshot(page, '3_6_image_uploaded')
# === Step 4: 选模式 "沉浸式短片" ===
# 关键: 用 Playwright locator.click() 而不是 JS .click()
# 因为 React 事件系统只响应真实 DOM 事件
print("[视频] [Step 4] Selecting mode: 沉浸式短片...")
# 4a: 点击 "模式" 下拉按钮在工具栏里用 text= 匹配
mode_opened = await safe_click(page, page.locator('text=模式').nth(0), '模式下拉')
await page.wait_for_timeout(2000)
await screenshot(page, '4a_dropdown')
if mode_opened:
# 4b: 在下拉菜单中点击 "沉浸式短片"
# 下拉菜单中有三个选项沉浸式短片智能长视频图片
# 需要精确点击菜单项避免点到左侧边栏
# 策略用 text= 匹配但限定区域 (排除左侧 sidebar x<220)
mode_selected = await page.evaluate('''() => {
const items = Array.from(document.querySelectorAll('*'));
// 找到所有包含"沉浸式短片"的元素
const candidates = items.filter(el => {
const text = el.innerText && el.innerText.trim();
return text === '沉浸式短片' && el.offsetHeight < 50 && el.offsetHeight > 10;
});
// 按x坐标排序优先选择靠近中间的下拉菜单的位置
candidates.sort((a, b) => {
const ra = a.getBoundingClientRect();
const rb = b.getBoundingClientRect();
return rb.left - ra.left; // 先取 x 最大的
});
for (const el of candidates) {
const rect = el.getBoundingClientRect();
if (rect.left > 300) {
// 通过 dispatchEvent 模拟完整的鼠标事件链
el.dispatchEvent(new MouseEvent('mousedown', {bubbles:true, cancelable:true}));
el.dispatchEvent(new MouseEvent('mouseup', {bubbles:true, cancelable:true}));
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true}));
return 'selected (x=' + Math.round(rect.left) + ', y=' + Math.round(rect.top) + ')';
}
}
// 返回调试信息
return 'NOT_FOUND: candidates=' + candidates.map(el => {
const r = el.getBoundingClientRect();
return '(' + Math.round(r.left) + ',' + Math.round(r.top) + ')';
}).join(';');
}''')
print(f" 沉浸式短片: {mode_selected}")
await page.wait_for_timeout(3000)
await screenshot(page, '4b_mode_selected')
# === Step 5: 选模型 ===
print(f" [Step 5] Selecting model: {model}...")
# 5a: 精确点击工具栏的 "2.0 Fast" 按钮
# 关键约束: text.length < 15 排除匹配到整个工具栏容器
model_click = await page.evaluate('''() => {
const items = Array.from(document.querySelectorAll('*'));
const btn = items.find(el => {
const text = el.innerText && el.innerText.trim();
if (!text || !text.includes('2.0')) return false;
// 关键: 文本长度 < 15只匹配 "2.0 Fast" 这样的短文本
// 排除整个工具栏容器 ("沉浸式短片\\n2.0 Fast\\n参考\\n5s")
if (text.length > 15) return false;
const rect = el.getBoundingClientRect();
// 工具栏区域: y > 370, x > 600, 小元素
return rect.top > 370 && rect.left > 600 && el.offsetHeight < 50 && el.offsetHeight > 15;
});
if (btn) {
btn.dispatchEvent(new MouseEvent('mousedown', {bubbles:true, cancelable:true}));
btn.dispatchEvent(new MouseEvent('mouseup', {bubbles:true, cancelable:true}));
btn.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true}));
const r = btn.getBoundingClientRect();
return 'opened: ' + btn.innerText.trim() + ' (x=' + Math.round(r.left) + ', y=' + Math.round(r.top) + ')';
}
return 'NOT_FOUND';
}''')
print(f" Model button: {model_click}")
await page.wait_for_timeout(2000)
await screenshot(page, '5a_model_dropdown')
if 'opened' in model_click:
# 5b: 在下拉菜单中选目标模型
# 下拉结构:
# "Seedance 2.0 Fast" (标题) + "更快更便宜的Seedance 2.0模型" (描述)
# "Seedance 2.0" (标题) + "15 秒内效果无损..." (描述)
# "Seedance 1.5" (标题) + "画面直出..." (描述)
# 关键: 标题行是纯英文/数字/空格/点描述行含中文
model_select = await page.evaluate('''([wantFast]) => {
const items = Array.from(document.querySelectorAll('*'));
const candidates = items.filter(el => {
const text = el.innerText && el.innerText.trim();
if (!text) return false;
// 只匹配纯英文+数字+空格+点的标题行排除含中文的描述行
if (!/^Seedance\s+\d/.test(text)) return false;
// 不能含中文字符
if (/[\u4e00-\u9fff]/.test(text)) return false;
if (el.offsetHeight > 40 || el.offsetHeight < 10) return false;
const rect = el.getBoundingClientRect();
return rect.left > 300 && rect.left < 1100 && rect.top > 400;
});
for (const el of candidates) {
const text = el.innerText.trim();
const isFast = text.includes('Fast');
if (wantFast === isFast) {
el.dispatchEvent(new MouseEvent('mousedown', {bubbles:true, cancelable:true}));
el.dispatchEvent(new MouseEvent('mouseup', {bubbles:true, cancelable:true}));
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true}));
const r = el.getBoundingClientRect();
return 'selected: ' + text + ' (x=' + Math.round(r.left) + ', y=' + Math.round(r.top) + ')';
}
}
return 'NOT_FOUND: candidates=' + candidates.map(el => {
const r = el.getBoundingClientRect();
return '"' + el.innerText.trim() + '"(x=' + Math.round(r.left) + ',y=' + Math.round(r.top) + ')';
}).join('; ');
}''', ["Fast" in model])
print(f" Model select: {model_select}")
await page.wait_for_timeout(1500)
await screenshot(page, '5b_model_selected')
# === Step 6: 上传参考视频 (仅 V2V 模式) ===
if ref_video:
print(f" [Step 6] Uploading reference video: {os.path.basename(ref_video)}")
# 6a: 点击工具栏的 "参考" 按钮 弹出面板
# "参考" 文字可能在多处出现用坐标限制到工具栏区域 (y>370)
ref_click_result = await page.evaluate('''() => {
const items = Array.from(document.querySelectorAll('*'));
const btn = items.find(el => {
const text = el.innerText && el.innerText.trim();
if (text !== '参考') return false;
const rect = el.getBoundingClientRect();
// 工具栏区域: y > 370, 小元素
return rect.top > 370 && el.offsetHeight < 50 && el.offsetHeight > 10;
});
if (btn) {
btn.dispatchEvent(new MouseEvent('mousedown', {bubbles:true, cancelable:true}));
btn.dispatchEvent(new MouseEvent('mouseup', {bubbles:true, cancelable:true}));
btn.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true}));
const r = btn.getBoundingClientRect();
return 'OK (x=' + Math.round(r.left) + ', y=' + Math.round(r.top) + ')';
}
return 'NOT_FOUND';
}''')
ref_clicked = 'OK' in ref_click_result
print(f" 参考按钮: {ref_click_result}")
await page.wait_for_timeout(3000)
await screenshot(page, '6a_ref_panel')
if ref_clicked:
# 6b: 在弹出面板中点击 "从本地上传" 链接
# 面板结构:
# 输入框: "请输入参考内容链接或从本地上传"
# 描述: "仅支持抖音头条西瓜你也可以 [从本地上传]" 紫色链接
# 按钮: [确认]
try:
async with page.expect_file_chooser(timeout=10000) as fc_info:
# 精确点击 "从本地上传" 链接 (紫色文字在描述行中)
upload_clicked = await page.evaluate('''() => {
// 方式1: 找精确匹配 "从本地上传" 的元素 (最小的那个即链接本身)
const all = Array.from(document.querySelectorAll('*'));
const candidates = all.filter(el => {
const text = el.innerText && el.innerText.trim();
if (!text) return false;
// 精确匹配: 文本就是 "从本地上传" (不含其他文字)
if (text === '从本地上传') return true;
return false;
});
// 按元素大小排序取最小的 (最精确的)
candidates.sort((a, b) => {
return (a.offsetWidth * a.offsetHeight) - (b.offsetWidth * b.offsetHeight);
});
if (candidates.length > 0) {
const el = candidates[0];
el.click();
return 'OK_link: ' + el.tagName;
}
return 'NOT_FOUND';
}''')
print(f" 从本地上传: {upload_clicked}")
if upload_clicked == 'NOT_FOUND':
raise Exception("'从本地上传' link not found in popup")
file_chooser = await fc_info.value
await file_chooser.set_files(ref_video)
print(f" [OK] 文件已选择: {os.path.basename(ref_video)}")
# 等待上传: 弹窗会显示上传进度完成后弹窗关闭页面出现缩略图
print(" [等待] 等待上传完成...")
for wait_i in range(60): # 最多等 300 秒
await page.wait_for_timeout(5000)
upload_status = await page.evaluate('''() => {
// 检测1: 弹窗是否还在 (如果弹窗关闭了大概率上传完成)
const popup = document.querySelector('[class*="modal"], [class*="dialog"], [role="dialog"]');
const hasConfirmBtn = !!Array.from(document.querySelectorAll('*')).find(el =>
el.innerText && el.innerText.trim() === '确认' &&
el.offsetHeight < 50 && el.offsetHeight > 15
);
// 检测2: 视频缩略图元素是否出现 (带 video 标签或 img)
const inputArea = document.querySelector('div[contenteditable="true"]');
const hasThumb = inputArea ?
(inputArea.parentElement.querySelector('video') !== null ||
inputArea.parentElement.querySelector('img[src*="tos"]') !== null) :
false;
// 检测3: 是否有上传进度指示
const html = document.body.innerHTML;
const isUploading = html.includes('上传中') || html.includes('uploading');
if (hasThumb) return 'DONE';
if (isUploading) return 'UPLOADING';
if (!hasConfirmBtn && !popup) return 'POPUP_CLOSED';
return 'WAITING';
}''')
if upload_status == 'DONE':
print(f" [OK] 上传完成! 缩略图已出现 (elapsed: {(wait_i+1)*5}s)")
break
elif upload_status == 'POPUP_CLOSED':
print(f" [OK] 弹窗已关闭 (elapsed: {(wait_i+1)*5}s)")
break
elif upload_status == 'UPLOADING':
print(f" [等待] 上传中... ({(wait_i+1)*5}s)")
elif wait_i > 0 and wait_i % 6 == 0:
print(f" [等待] 等待中... ({(wait_i+1)*5}s)")
# 如果弹窗还开着尝试关闭 (点 X 按钮)
await page.evaluate('''() => {
// 找 X 关闭按钮
const closeBtn = document.querySelector('[class*="close"], [aria-label="close"], [aria-label="关闭"]');
if (closeBtn) { closeBtn.click(); return; }
// 找确认按钮
const all = Array.from(document.querySelectorAll('*'));
const confirm = all.find(el => el.innerText && el.innerText.trim() === '确认'
&& el.offsetHeight < 50 && el.offsetHeight > 15);
if (confirm) confirm.click();
}''')
await page.wait_for_timeout(2000)
except Exception as e:
print(f" [ERR] 参考视频上传失败: {e}")
await page.wait_for_timeout(2000)
await screenshot(page, '6b_ref_uploaded')
# === Step 7: 选时长 ===
step7_label = '7' if ref_video else '6'
print(f" [Step {step7_label}] Selecting duration: {duration}...")
# 点击当前时长按钮 (显示 "5s""10s" 或 "15s")
dur_btn = page.locator('text=/^\\d+s$/').first
dur_opened = await safe_click(page, dur_btn, '时长按钮')
await page.wait_for_timeout(1500)
await screenshot(page, f'{step7_label}a_duration_dropdown')
if dur_opened:
try:
dur_item = page.locator(f'text=/^{duration}$/').first
await dur_item.click(timeout=3000)
print(f" [OK] 时长选择: {duration}")
except Exception as e:
print(f" [警告] 时长选择: {e}")
await page.wait_for_timeout(1000)
await screenshot(page, f'{step7_label}b_duration_selected')
# === Step 8: 注入 Prompt ===
step8_label = '8' if ref_video else '7'
print(f"[输入] [Step {step8_label}] Injecting prompt: {prompt}")
inject_result = await page.evaluate('''([text]) => {
const el = document.querySelector('div[contenteditable="true"]');
if (el) {
el.innerText = text;
el.dispatchEvent(new Event('input', { bubbles: true }));
return 'OK: ' + el.innerText.substring(0, 30) + '...';
}
return 'FAILED: no contenteditable found';
}''', [prompt])
print(f" Inject: {inject_result}")
await page.wait_for_timeout(1000)
await screenshot(page, f'{step8_label}_prompt')
# === Step 8: 验证/提交 ===
if dry_run:
await screenshot(page, '8_DRY_RUN_FINAL')
status_text = await page.evaluate('''() => {
const all = Array.from(document.querySelectorAll('*'));
const info = all.find(el => {
const t = el.innerText && el.innerText.trim();
return t && t.includes('积分') && t.includes('秒') && el.offsetHeight < 40;
});
return info ? info.innerText.trim() : 'NOT_FOUND';
}''')
print(f"\n[OK] DRY-RUN 完成请检查截图 step_8_DRY_RUN_FINAL.png")
print(f" 底部状态栏: {status_text}")
print(f"\n确认无误后去掉 --dry-run 参数重新运行即可提交任务")
await browser.close()
return
# === Step 8: 设置 thread_id 拦截器 + 提交 ===
thread_id = None
async def sniff_thread(response):
nonlocal thread_id
if thread_id:
return
try:
text = await response.text()
if 'thread_id' in text:
import json as _json
# 尝试从 JSON 中提取 thread_id
data = _json.loads(text)
# thread_id 可能在不同层级
tid = None
if isinstance(data, dict):
tid = data.get('thread_id') or data.get('data', {}).get('thread_id')
if not tid and 'data' in data:
d = data['data']
if isinstance(d, dict):
tid = d.get('thread_id')
# 可能嵌套更深
for v in d.values():
if isinstance(v, dict) and 'thread_id' in v:
tid = v['thread_id']
break
if not tid:
# 暴力正则
m = re.search(r'"thread_id"\s*:\s*"([^"]+)"', text)
if m:
tid = m.group(1)
if tid:
thread_id = tid
print(f"\n Sniffed thread_id: {tid}")
except Exception:
pass
page.on('response', sniff_thread)
print(" [Step 8] Clicking '开始创作'...")
submit_clicked = await safe_click(page, page.locator('text=开始创作').first, '开始创作')
await page.wait_for_timeout(5000)
await screenshot(page, '8_submitted')
if not submit_clicked:
print(" [ERR] Submit failed. Aborting.")
await browser.close()
return
# 等待 thread_id 被拦截
for _ in range(10):
if thread_id:
break
await page.wait_for_timeout(2000)
if not thread_id:
print(" [警告] thread_id not captured from responses, trying page HTML...")
page_html = await page.content()
m = re.search(r'thread_id["\s:=]+([0-9a-f-]{36})', page_html)
if m:
thread_id = m.group(1)
print(f" Found thread_id in HTML: {thread_id}")
if not thread_id:
print(" [ERR] Could not get thread_id. Aborting.")
await browser.close()
return
# === Step 9: 导航到 thread 详情页 + 轮询视频 ===
detail_url = f"https://xyq.jianying.com/home?tab_name=integrated-agent&thread_id={thread_id}"
print(f"[链接] [Step 9] Navigating to thread detail page...")
print(f" URL: {detail_url}")
await page.goto(detail_url, wait_until='domcontentloaded')
await page.wait_for_timeout(8000)
safe_name = ''.join(c for c in prompt[:15] if c.isalnum() or c in '_ ')
filename = f"{safe_name}_{duration}.mp4"
filepath = os.path.join(DOWNLOAD_DIR, filename)
print("[等待] Polling for video on detail page...")
mp4_url = None
for i in range(120):
await page.wait_for_timeout(5000)
# 双通道提取: DOM + 正则
mp4_url = await page.evaluate('''() => {
// 通道1: <video> 标签 src
const v = document.querySelector('video');
if (v && v.src && v.src.includes('.mp4')) return v.src;
const s = document.querySelector('video source');
if (s && s.src && s.src.includes('.mp4')) return s.src;
// 通道2: 暴力正则
const html = document.documentElement.innerHTML;
const m = html.match(/https?:\/\/[^"'\\s\\\\]+\.mp4[^"'\\s\\\\]*/);
return m ? m[0] : null;
}''')
if mp4_url:
mp4_url = html.unescape(mp4_url)
print(f"\n [完成] Found MP4 at attempt {i+1}!")
print(f" [链接] {mp4_url[:120]}...")
break
if i % 12 == 0 and i > 0:
print(f" [等待] Still generating... ({i*5}s elapsed)")
# 刷新详情页
await page.reload(wait_until='domcontentloaded')
await page.wait_for_timeout(5000)
print(".", end="", flush=True)
if not mp4_url:
print("\n [ERR] Timeout after 10 min")
await screenshot(page, '9_timeout')
await browser.close()
return
await screenshot(page, '9_video_ready')
# === Step 10: curl 下载 ===
print(f" [Step 10] Downloading to {filepath}...")
import subprocess
result = subprocess.run(
['curl', '-L', '-o', filepath, '-s', '-w', '%{http_code}', mp4_url],
capture_output=True, text=True, timeout=120
)
http_code = result.stdout.strip()
if os.path.exists(filepath) and os.path.getsize(filepath) > 10000:
size_mb = os.path.getsize(filepath) / (1024 * 1024)
print(f" [OK] Saved: {os.path.abspath(filepath)} ({size_mb:.1f}MB) [HTTP {http_code}]")
else:
print(f" [ERR] Download failed: HTTP {http_code}")
if result.stderr:
print(f" Error: {result.stderr[:200]}")
print(f" Manual link: {mp4_url}")
await browser.close()
print("\n Done!")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Jianying SeeDance 2.0 Video Generator")
parser.add_argument("--prompt", type=str, default="一个美女在跳舞", help="Video description")
parser.add_argument("--duration", type=str, default="10s", choices=["5s", "10s", "15s"])
parser.add_argument("--ratio", type=str, default="横屏", choices=["横屏", "竖屏", "方屏"])
parser.add_argument("--model", type=str, default="Seedance 2.0",
choices=["Seedance 2.0", "Seedance 2.0 Fast"])
parser.add_argument("--ref-video", type=str, default=None, help="Reference video file path (V2V mode)")
parser.add_argument("--ref-image", type=str, default=None, help="Reference image file path (I2V mode)")
parser.add_argument("--cookies", type=str, default="cookies.json", help="Path to cookies.json")
parser.add_argument("--output-dir", type=str, default=".", help="Directory to save output video")
parser.add_argument("--dry-run", action="store_true", help="Only fill form, don't submit")
args = parser.parse_args()
COOKIES_FILE = args.cookies
DOWNLOAD_DIR = args.output_dir
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
if not os.path.exists(COOKIES_FILE):
print(f"[警告] {COOKIES_FILE} not found!")
else:
asyncio.run(run(args.prompt, args.duration, args.ratio, args.model, args.dry_run, args.ref_video, args.ref_image))