
Jianying Video Gen
- 526 installs
- 249 repo stars
- Updated April 4, 2026
- cclank/lanshu-waytovideo
jianying-video-gen is a Python Playwright agent skill that automates Jianying Seedance 2.0 AI video generation in four modes—T2V, I2V, V2V, and Extend—for developers who need CLI-driven short-form video from text, images
About
jianying-video-gen is a skill from cclank/lanshu-waytovideo that automates AI video creation on Jianying (剪映/小云雀) using the Seedance 2.0 model through Playwright browser control of xyq.jianying.com. It supports four generation modes: text-to-video (T2V), image-to-video (I2V), reference video-to-video (V2V), and backward extension (Extend) of existing clips. Prerequisites include Python 3.9+, playwright with Chromium installed via playwright install chromium, and a cookies.json login export placed beside scripts/jianying_worker.py. Example usage runs python3 scripts/jianying_worker.py with --cookies and --output-dir flags. Developers use it to batch-generate marketing clips, style-transfer experiments, or extend prior renders without manual browser clicking. The skill is China-ecosystem specific and requires valid Jianying session cookies. Skip it for teams without Jianying access or projects standardizing on other video APIs like Runway or Sora.
- Automates Jianying (xyq.jianying.com) via Playwright to run Seedance 2.0
- Supports four generation modes: T2V (text-to-video), I2V (image-to-video), V2V (video reference), and Extend
- CLI-first workflow with full control over prompt, duration, model variant and output directory
- Requires cookies.json login credential exported from browser
- Runs on local Chromium for full browser fidelity
Jianying Video Gen by the numbers
- 526 all-time installs (skills.sh)
- Ranked #394 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cclank/lanshu-waytovideo --skill jianying-video-genAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 526 |
|---|---|
| repo stars | ★ 249 |
| Last updated | April 4, 2026 |
| Repository | cclank/lanshu-waytovideo ↗ |
How do you automate Jianying Seedance video generation from CLI?
Automate high-quality AI video generation using Jianying's Seedance 2.0 model directly from the command line.
Who is it for?
Developers with Jianying accounts who want scripted Seedance 2.0 video generation across four modes without manual browser work.
Skip if: Teams without Jianying login cookies or projects standardized on Western-hosted video generation APIs.
When should I use this skill?
The user wants AI video from Seedance 2.0, Jianying automation, or CLI batch generation using text, images, or reference videos.
What you get
Rendered AI video files in the output directory from T2V, I2V, V2V, or Extend runs via Playwright automation.
- Generated AI video files
- Automated Playwright worker runs
By the numbers
- 4 generation modes: T2V, I2V, V2V, and Extend
- Requires Python 3.9+ and Playwright Chromium
Files
剪映 AI 视频生成器
通过 Playwright 自动化操作剪映(xyq.jianying.com),使用 Seedance 2.0 模型生成 AI 视频。
前置条件
1. Python 3.9+ + playwright 已安装 2. Chromium 已通过 playwright install chromium 安装 3. cookies.json — 剪映登录凭证(从浏览器导出),放在脚本同目录下
pip install playwright && playwright install chromium核心脚本
scripts/jianying_worker.py — 主自动化脚本
使用方式
文生视频 (T2V)
python3 scripts/jianying_worker.py \
--cookies /path/to/cookies.json \
--output-dir /path/to/output \
--prompt "赛博朋克风格的长安城,飞行汽车穿梭在霓虹灯笼之间" \
--duration 10s \
--model "Seedance 2.0"图生视频 (I2V)
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)
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"向后延伸 (Extend)
python3 scripts/jianying_worker.py \
--cookies /path/to/cookies.json \
--output-dir /path/to/output \
--extend-url 'https://xyq.jianying.com/home?tab_name=integrated-agent&thread_id=YOUR_THREAD_ID&agent_name=pippit_video_part_agent' \
--prompt "向后延伸,保持人物和镜头语言一致" \
--duration 5sDry-Run 模式(调试用)
python3 scripts/jianying_worker.py --cookies /path/to/cookies.json --prompt "测试" --dry-run只填写表单不提交,生成 step_*.png 截图供检查。对 V2V 来说,dry-run 现在还会输出一段 表单状态 JSON,用来确认参考视频缩略图是否已挂上、发送按钮是否已解锁。参数说明
| 参数 | 默认值 | 可选值 | 说明 |
|---|---|---|---|
--prompt | "一个美女在跳舞" | 任意文本 | 视频描述 |
--duration | 10s | 5s, 10s, 15s | 视频时长 |
--ratio | 横屏 | 横屏, 竖屏, 方屏 | 画面比例 |
--model | Seedance 2.0 | Seedance 2.0, Seedance 2.0 Fast | 模型选择 |
--ref-image | 无 | 本地图片路径 | I2V 模式的参考图片 |
--ref-video | 无 | 本地视频路径 | V2V 模式的参考视频 |
--extend-url | 无 | thread 链接 | Extend 模式,基于已有视频继续生成 |
--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) → 选模式(沉浸式短片) → 选模型 → [上传参考素材/打开已有 thread 并点向后延伸] → 选时长 → 输入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 分钟。
import json
import sys
def json_to_netscape(json_file, netscape_file):
with open(json_file, 'r') as f:
cookies = json.load(f)
with open(netscape_file, 'w') as f:
f.write("# Netscape HTTP Cookie File\n")
f.write("# http://curl.haxx.se/rfc/cookie_spec.html\n")
f.write("# This is a generated file! Do not edit.\n\n")
for cookie in cookies:
domain = cookie.get('domain', '')
# Netscape format requires domains starting with . to have a flag
flag = "TRUE" if domain.startswith('.') else "FALSE"
path = cookie.get('path', '/')
secure = "TRUE" if cookie.get('secure', False) else "FALSE"
# Expiration date: handle different keys
expiry = cookie.get('expirationDate') or cookie.get('expiry') or cookie.get('expires', 0)
name = cookie.get('name', '')
value = cookie.get('value', '')
f.write(f"{domain}\t{flag}\t{path}\t{secure}\t{int(expiry)}\t{name}\t{value}\n")
if __name__ == "__main__":
json_to_netscape(sys.argv[1], sys.argv[2])
Seedance 2.0 提示词编写指南
核心原则
Seedance 2.0 对中文提示词有很好的理解。写提示词时注重:主体 + 动作 + 风格 + 镜头 + 环境。
提示词模板
[主体描述],[动作/行为],[画面风格],[镜头语言],[环境/光影]经典示例
🎬 运动 & 动作
一位武术大师在雨中练太极,慢动作,水滴飞溅,电影级光影
三个街舞少年在霓虹灯下的篮球场battle,地面反光,烟雾弥漫🌊 自然 & 特效
一朵玫瑰从花苞到盛开的延时摄影,露珠滚落花瓣,背景虚化,4K微距
巨龙从火山口腾空而起,熔岩四溅,暴风雨中闪电照亮整个天空,电影级航拍🎥 镜头语言
镜头穿越东京涩谷十字路口的人群,从地面缓慢上升到鸟瞰视角,夜景霓虹灯,电影质感
一颗眼泪从眼角滑落的极致特写,然后镜头拉远,露出一个站在雨中的女孩全身🔥 风格化
赛博朋克风格的长安城,飞行汽车穿梭在霓虹灯笼之间,古代宫殿与全息投影交融,雾气弥漫的街道
中国水墨画风格,仙女在云端起舞,飘逸的白色长裙随风飘动,山水画背景,仙鹤环绕,月光如瀑🏯 超现实
一座中国古城从海底缓缓升起,鲸鱼在宫殿之间游过,阳光穿透海水照亮金色琉璃瓦,水下摄影
一杯咖啡慢动作倾倒,液体在空中形成完美的抛物线,溅落在白色大理石台面上,每一滴都清晰可见V2V 参考视频提示词
参考视频模式的提示词重点是描述风格变换,保留原视频的动作和构图:
参考视频,画风改成宫崎骏风格的画风,其他不变
赛博朋克风格,霓虹灯光照射下的机械舞者,金属质感皮肤,全息投影背景
三只穿着华丽晚礼服的猫咪在舞会上优雅起舞,迪士尼动画风格,水晶吊灯,金色大厅积分优化策略
- 测试阶段: 用
--model "Seedance 2.0 Fast" --duration 5s(15积分/次) - 正式出片: 用
--model "Seedance 2.0" --duration 10s(50积分/次)
playwright>=1.42.0
requests>=2.31.0
#!/usr/bin/env python3
"""查找剪映 Seedance 最新任务的 thread_id - 通过导航详情页查找"""
import asyncio, json, sys, re
from playwright.async_api import async_playwright
async def main():
with open('cookies.json') as f:
cookies_data = json.load(f)
# Filter cookies
valid_same_site = {'Strict', 'Lax', 'None'}
filtered_cookies = []
for c in cookies_data:
cookie = dict(c)
if 'sameSite' in cookie and cookie['sameSite'] not in valid_same_site:
cookie['sameSite'] = 'Lax'
filtered_cookies.append(cookie)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
await context.add_cookies(filtered_cookies)
page = await context.new_page()
# Listen to all responses
responses = []
def handle_response(response):
responses.append({
'url': response.url,
'status': response.status
})
page.on('response', handle_response)
print("🔍 Navigating to Seedance integrated agent...")
await page.goto("https://xyq.jianying.com/home?tab_name=integrated-agent&agent_name=pippit_video_part_agent", wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(5000)
print(f"Captured {len(responses)} responses")
# Check localStorage and sessionStorage
local_storage = await page.evaluate("() => JSON.stringify(localStorage)")
session_storage = await page.evaluate("() => JSON.stringify(sessionStorage)")
# Search for thread_id in storage
for storage_name, storage_data in [('localStorage', local_storage), ('sessionStorage', session_storage)]:
m = re.search(r'"thread_id"\s*:\s*"([0-9a-f-]{36})"', storage_data)
if m:
print(f"🎯 Found thread_id in {storage_name}: {m.group(1)}")
await browser.close()
return m.group(1)
# Also search for any UUID-like patterns
uuids = re.findall(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', storage_data)
if uuids:
unique_uuids = list(set(uuids))
print(f"Found {len(unique_uuids)} unique UUIDs in {storage_name}: {unique_uuids[:5]}")
# Check window.__INITIAL_STATE__ or similar global variables
initial_state = await page.evaluate("""() => {
if (window.__INITIAL_STATE__) return JSON.stringify(window.__INITIAL_STATE__);
if (window.__NUXT__) return JSON.stringify(window.__NUXT__);
if (window.__NEXT_DATA__) return JSON.stringify(window.__NEXT_DATA__);
return null;
}""")
if initial_state:
m = re.search(r'"thread_id"\s*:\s*"([0-9a-f-]{36})"', initial_state)
if m:
print(f"🎯 Found thread_id in window state: {m.group(1)}")
await browser.close()
return m.group(1)
# Look at the page structure - find clickable task items
print("\n🔍 Looking for task list items...")
# Try to find any element that looks like a task card and click the first one
# The task list is usually in a sidebar
task_items = await page.evaluate("""() => {
// Look for elements that might be task items
const selectors = [
'[class*="task"]',
'[class*="card"]',
'[class*="item"]',
'[class*="list-item"]',
'a[href*="thread"]',
'[role="listitem"]'
];
let items = [];
for (const sel of selectors) {
const els = document.querySelectorAll(sel);
for (const el of els) {
if (el.textContent.trim().length > 3 && !items.includes(el)) {
items.push({
text: el.textContent.trim().substring(0, 50),
href: el.href || el.getAttribute('href') || '',
class: el.className.substring(0, 50)
});
}
}
}
return items.slice(0, 10);
}""")
print(f"Found {len(task_items)} task items:")
for item in task_items:
print(f" - {item['text'][:40]}... href: {item['href'][:60] if item['href'] else 'none'}")
# Look for href with thread_id
for item in task_items:
if item['href']:
m = re.search(r'thread_id=([0-9a-f-]{36})', item['href'])
if m:
print(f"\n🎯 Found thread_id in task href: {m.group(1)}")
await browser.close()
return m.group(1)
# Save debug screenshot
await page.screenshot(path='debug_tasks2.png', full_page=True)
print("📸 Saved debug screenshot")
await browser.close()
return None
if __name__ == '__main__':
tid = asyncio.run(main())
if tid:
print(f"\n✅ thread_id: {tid}")
else:
print("\n❌ Could not find thread_id")
sys.exit(1)
"""
小云雀 (Jianying) 自动化视频生成 v5
引擎: Playwright + Chromium
支持: 文生视频 (T2V) + 参考视频生成 (V2V)
"""
import asyncio
import json
import re
import os
import html
import argparse
import subprocess
import shutil
import tempfile
from pathlib import Path
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 控制
EXTEND_BUTTON_PATTERNS = [
"向后延伸",
"后延伸",
"延伸",
"续写",
"继续创作",
"继续生成",
]
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 goto_with_retry(page, url: str, attempts: int = 3, wait_until: str = 'domcontentloaded'):
last_error = None
for idx in range(attempts):
try:
await page.goto(url, wait_until=wait_until)
return True
except Exception as e:
last_error = e
print(f" ⚠️ 导航失败,第 {idx + 1}/{attempts} 次: {e}")
if idx < attempts - 1:
await page.wait_for_timeout(2500)
if last_error:
raise last_error
return False
def extract_thread_id_from_text(text: str):
try:
data = json.loads(text)
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 tid:
return tid
except Exception:
pass
m = re.search(r'"thread_id"\s*:\s*"([^"]+)"', text)
if m:
return m.group(1)
return None
async def submit_and_capture_thread(page, screenshot_name: str):
thread_id = None
async def sniff_thread(response):
nonlocal thread_id
if thread_id:
return
try:
text = await response.text()
if 'thread_id' not in text:
return
tid = extract_thread_id_from_text(text)
if tid:
thread_id = tid
print(f"\n 🎯 Sniffed thread_id: {tid}")
except Exception:
pass
print(f" [Network] {response.status} {response.url[:100]}")
# 同时监听 URL 变化来捕获 thread_id
def on_url_change(url: str):
nonlocal thread_id
if thread_id:
return
m = re.search(r'thread_id=([0-9a-f-]{36})', url)
if m:
thread_id = m.group(1)
print(f"\n 🎯 Captured thread_id from URL nav: {thread_id}")
page.on('response', sniff_thread)
page.on('framenavigated', lambda frame: on_url_change(frame.url) if frame == page.main_frame else None)
try:
submit_clicked = await safe_click(
page, page.locator('button:has(svg.lucide-arrow-up)').first, '发送(箭头)', timeout=5000
)
if not submit_clicked:
print(" ❌ Submit failed. Aborting.")
return None
# 等待最多 30 秒让页面导航/响应到位
print(" ⏳ Waiting for thread_id (up to 30s)...")
for i in range(15):
await page.wait_for_timeout(2000)
if thread_id:
break
# 每次循环都主动检查当前 URL
current_url = page.url
m = re.search(r'thread_id=([0-9a-f-]{36})', current_url)
if m:
thread_id = m.group(1)
print(f" 🎯 Found thread_id in URL (poll {i+1}): {thread_id}")
break
await screenshot(page, screenshot_name)
if not thread_id:
print(" ⚠️ 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}")
# Last resort: navigate to integrated-agent and find latest task
if not thread_id:
print(" ⚠️ Trying to find latest task from task list...")
await page.goto("https://xyq.jianying.com/home?tab_name=integrated-agent&agent_name=pippit_video_part_agent", wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(5000)
# Look for links with thread_id
html = await page.content()
links = re.findall(r'href="[^"]*thread_id=([0-9a-f-]{36})', html)
if links:
thread_id = links[0]
print(f" 🎯 Found thread_id from task list: {thread_id}")
# Check localStorage for thread_id
if not thread_id:
storage = await page.evaluate("() => JSON.stringify(localStorage)")
m = re.search(r'"thread_id"\s*:\s*"([0-9a-f-]{36})"', storage)
if m:
thread_id = m.group(1)
print(f" 🎯 Found thread_id in localStorage: {thread_id}")
if not thread_id:
print(f" ⚠️ [DEBUG] current url: {page.url}")
await page.screenshot(path='FAILED_submit.png')
print(" ❌ Could not get thread_id. Aborting.")
return None
return thread_id
finally:
page.remove_listener('response', sniff_thread)
async def open_thread_and_download(page, thread_id: str, prompt: str, duration: str):
detail_url = f"https://xyq.jianying.com/home?tab_name=integrated-agent&thread_id={thread_id}"
print(f"🔗 Navigating to thread detail page...")
print(f" URL: {detail_url}")
await goto_with_retry(page, detail_url)
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(240):
await page.wait_for_timeout(5000)
mp4_url = await page.evaluate(r'''() => {
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;
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 ❌ Timeout after 20 min")
await screenshot(page, '9_timeout')
return False
await screenshot(page, '9_video_ready')
print(f"📥 Downloading to {filepath}...")
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" ✅ Saved: {os.path.abspath(filepath)} ({size_mb:.1f}MB) [HTTP {http_code}]")
return True
print(f" ❌ Download failed: HTTP {http_code}")
if result.stderr:
print(f" Error: {result.stderr[:200]}")
print(f" 📋 Manual link: {mp4_url}")
return False
async def check_and_resize_video(video_path: str) -> str:
"""检查视频分辨率,必要时缩放并补边到平台要求范围内。"""
try:
# 获取分辨率
cmd = ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=s=x:p=0", video_path]
proc = await asyncio.create_subprocess_exec(*cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
print(f" ⚠️ 无法获取视频分辨率: {stderr.decode()}")
return video_path
dims = stdout.decode().strip().split('x')
if len(dims) != 2:
return video_path
w, h = int(dims[0]), int(dims[1])
print(f" 📊 原始视频分辨率: {w}x{h}")
# 平台限制: 480p (640x640) - 720p (834x1112)
# 我们以长边不超过 1112 为准进行等比例缩放
max_dim = 1112
min_dim = 480
need_resize = max(w, h) > max_dim or min(w, h) < min_dim
if need_resize:
scale_ratio = min(1.0, max_dim / max(w, h)) if max(w, h) > max_dim else 1.0
scaled_w = max(2, int(round(w * scale_ratio)))
scaled_h = max(2, int(round(h * scale_ratio)))
if scaled_w % 2 != 0:
scaled_w -= 1
if scaled_h % 2 != 0:
scaled_h -= 1
pad_w = max(scaled_w, min_dim)
pad_h = max(scaled_h, min_dim)
if pad_w % 2 != 0:
pad_w += 1
if pad_h % 2 != 0:
pad_h += 1
filter_parts = [f"scale={scaled_w}:{scaled_h}"]
if pad_w != scaled_w or pad_h != scaled_h:
pad_x = max((pad_w - scaled_w) // 2, 0)
pad_y = max((pad_h - scaled_h) // 2, 0)
filter_parts.append(f"pad={pad_w}:{pad_h}:{pad_x}:{pad_y}:black")
filter_chain = ",".join(filter_parts)
print(f" 🔧 视频将处理为 {scaled_w}x{scaled_h},最终画布 {pad_w}x{pad_h}")
temp_dir = tempfile.gettempdir()
output_path = os.path.join(temp_dir, f"resized_{os.path.basename(video_path)}")
ffmpeg_cmd = ["ffmpeg", "-y", "-i", video_path, "-vf", filter_chain, "-c:v", "libx264", "-crf", "23", "-preset", "fast", output_path]
print(f" 🎬 执行缩放: {' '.join(ffmpeg_cmd)}")
f_proc = await asyncio.create_subprocess_exec(*ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
await f_proc.communicate()
if f_proc.returncode == 0:
print(f" ✅ 缩放完成: {output_path}")
return output_path
else:
print(f" ❌ 缩放失败,忽略并使用原文件")
except Exception as e:
print(f" ⚠️ 预检查发生错误: {str(e)}")
return video_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" ✅ {label}: clicked")
return True
except Exception as e:
print(f" ❌ {label}: {e}")
return False
async def open_reference_material_panel(page) -> bool:
"""打开 V2V 的参考素材面板,必须优先走工具栏里的“参考”按钮。"""
selectors = [
('button:has-text("参考")', '参考'),
('button:has-text("素材")', '素材'),
('button[title="上传参考素材"]', '上传参考素材'),
]
for selector, label in selectors:
if await safe_click(page, page.locator(selector).first, f'{label}按钮', timeout=8000):
return True
fallback = await page.evaluate('''() => {
const editable = document.querySelector('div[contenteditable="true"]');
const root = editable ? (editable.closest('form') || editable.parentElement || document.body) : document.body;
const buttons = Array.from(root.querySelectorAll('button'));
const candidate = buttons.find(btn => {
const title = (btn.getAttribute('title') || '').trim();
const text = (btn.innerText || '').trim();
return title.includes('上传参考素材') || text === '参考' || text === '素材';
});
if (!candidate) return 'NOT_FOUND';
candidate.click();
return 'OK_JS';
}''')
print(f" 参考素材面板兜底: {fallback}")
return fallback.startswith('OK')
async def upload_reference_media(page, file_path: str, media_kind: str) -> bool:
"""
上传参考素材。优先直连 input[type=file],避免依赖“从本地上传”文案。
media_kind: 'image' | 'video'
"""
expect_token = 'video' if media_kind == 'video' else 'image'
if media_kind == 'video':
print(" ℹ️ V2V 强制走『参考 -> 从本地上传』入口")
local_upload_texts = ['从本地上传', '本地上传']
for text in local_upload_texts:
try:
async with page.expect_file_chooser(timeout=8000) as fc_info:
clicked = await page.evaluate('''([targetText]) => {
const all = Array.from(document.querySelectorAll('*'));
const candidates = all.filter(el => {
const text = (el.innerText || '').trim();
if (text !== targetText) return false;
const r = el.getBoundingClientRect();
return r.left > 350 && r.top > 250 && r.top < 900 && r.width > 20 && r.height > 10;
});
candidates.sort((a, b) => {
const ra = a.getBoundingClientRect();
const rb = b.getBoundingClientRect();
return (ra.top - rb.top) || (ra.left - rb.left);
});
const el = candidates[0];
if (!el) return 'NOT_FOUND';
el.click();
return 'CLICKED';
}''', [text])
print(f" {text}入口: {clicked}")
if clicked != 'CLICKED':
continue
chooser = await fc_info.value
await chooser.set_files(file_path)
print(f" ✅ 通过参考面板本地上传成功: {text}")
return True
except Exception as e:
print(f" ⚠️ {text}入口失败: {e}")
file_inputs = page.locator('input[type="file"]')
input_count = await file_inputs.count()
for idx in range(input_count):
locator = file_inputs.nth(idx)
try:
accept = (await locator.get_attribute('accept')) or ''
is_hidden = await locator.evaluate(
'''el => {
const s = window.getComputedStyle(el);
return s.display === 'none' || s.visibility === 'hidden';
}'''
)
if accept and expect_token not in accept.lower():
continue
await locator.set_input_files(file_path, timeout=10000)
print(f" ✅ 通过 file input 上传成功: index={idx}, accept={accept or '*/*'}, hidden={is_hidden}")
return True
except Exception as e:
print(f" ⚠️ file input[{idx}] 上传失败: {e}")
print(" ℹ️ 未找到可直接写入的 file input,回退到 file chooser 流程")
candidate_texts = ['从本地上传', '本地上传', '上传']
for text in candidate_texts:
try:
async with page.expect_file_chooser(timeout=5000) as fc_info:
clicked = await safe_click(page, page.locator(f'text={text}').first, f'{text}入口', timeout=3000)
if not clicked:
continue
chooser = await fc_info.value
await chooser.set_files(file_path)
print(f" ✅ 通过 file chooser 上传成功: {text}")
return True
except Exception:
continue
return False
async def confirm_reference_media(page) -> bool:
"""点击参考弹窗中的确认按钮,把已上传素材真正挂到编辑器。"""
try:
confirm_state = await page.evaluate('''() => {
const btn = Array.from(document.querySelectorAll('button')).find(el => (el.innerText || '').trim() === '确认');
if (!btn) return 'NOT_FOUND';
const disabled = btn.hasAttribute('disabled') || btn.getAttribute('aria-disabled') === 'true';
if (disabled) return 'DISABLED';
btn.click();
return 'CLICKED';
}''')
print(f" 参考确认按钮: {confirm_state}")
return confirm_state == 'CLICKED'
except Exception as e:
print(f" ❌ 点击参考确认失败: {e}")
return False
async def wait_for_reference_media_ready(page, media_kind: str, timeout_ms: int = 300000) -> bool:
"""等待参考素材缩略图或重传入口出现。"""
step_ms = 5000
expect_video = media_kind == 'video'
loops = max(timeout_ms // step_ms, 1)
print(" ⏳ 等待上传完成...")
for wait_i in range(loops):
await page.wait_for_timeout(step_ms)
upload_status = await page.evaluate(r'''([expectVideo]) => {
const text = document.body.innerText || '';
const isUploading = text.includes('上传中') || text.includes('uploading') || /\b\d{1,3}%\b/.test(text);
const confirmBtn = Array.from(document.querySelectorAll('button')).find(btn => (btn.innerText || '').trim() === '确认');
const confirmDisabled = confirmBtn ? (confirmBtn.hasAttribute('disabled') || btnHasSpinner(confirmBtn)) : null;
const editable = document.querySelector('div[contenteditable="true"]');
const scope = editable ? (editable.closest('form') || editable.parentElement || document.body) : document.body;
const hasBackgroundThumb = Array.from(document.body.querySelectorAll('*')).some(el => {
const rect = el.getBoundingClientRect();
if (rect.width < 20 || rect.height < 20) return false;
const style = window.getComputedStyle(el);
if (!style.backgroundImage || style.backgroundImage === 'none') return false;
return rect.top > 150 && rect.top < 500 && rect.left > 450 && rect.left < 900;
});
const hasVisual = expectVideo
? (!!scope.querySelector('video, img[src*="tos"], canvas') || hasBackgroundThumb)
: (!!scope.querySelector('img, canvas') || hasBackgroundThumb);
const all = Array.from(document.querySelectorAll('*'));
const hasLabel = all.some(el => {
const t = (el.innerText || '').trim();
if (!t) return false;
if (expectVideo) return t === '视频1' || t === '重新上传' || t === '替换';
return t === '图片1' || t === '重新上传' || t === '替换';
});
const sendBtn = Array.from(document.querySelectorAll('button')).find(btn => btn.querySelector('svg.lucide-arrow-up'));
const sendDisabled = sendBtn ? (sendBtn.hasAttribute('disabled') || sendBtn.getAttribute('aria-disabled') === 'true') : null;
if (hasVisual || hasLabel) return `DONE|sendDisabled=${sendDisabled}|confirmDisabled=${confirmDisabled}`;
if (isUploading || confirmDisabled) return 'UPLOADING';
return `WAITING|sendDisabled=${sendDisabled}|confirmDisabled=${confirmDisabled}`;
function btnHasSpinner(btn) {
return !!btn.querySelector('svg, [class*="spin"], [class*="loading"], [class*="loader"]');
}
}''', [expect_video])
if upload_status.startswith('DONE'):
print(f" ✅ 上传完成! {upload_status} (elapsed: {(wait_i + 1) * step_ms // 1000}s)")
return True
if upload_status == 'UPLOADING':
continue
if upload_status.startswith('WAITING|sendDisabled=false') and wait_i >= 11:
print(f" ⚠️ 上传完成信号不稳定,按可提交状态继续: {upload_status} (elapsed: {(wait_i + 1) * step_ms // 1000}s)")
return True
if wait_i > 0 and wait_i % 6 == 0:
print(f" ⏳ 等待中... {upload_status} ({(wait_i + 1) * step_ms // 1000}s)")
return False
async def collect_editor_state(page):
"""收集 dry-run 末态,便于判断表单是否可提交。"""
return await page.evaluate('''() => {
const editable = document.querySelector('div[contenteditable="true"]');
const scope = editable ? (editable.closest('form') || editable.parentElement || document.body) : document.body;
const sendBtn = Array.from(document.querySelectorAll('button')).find(btn => btn.querySelector('svg.lucide-arrow-up'));
const promptText = editable ? (editable.innerText || '').trim() : '';
const hasBackgroundThumb = Array.from(document.body.querySelectorAll('*')).some(el => {
const rect = el.getBoundingClientRect();
if (rect.width < 20 || rect.height < 20) return false;
const style = window.getComputedStyle(el);
if (!style.backgroundImage || style.backgroundImage === 'none') return false;
return rect.top > 150 && rect.top < 500 && rect.left > 450 && rect.left < 900;
});
return {
promptLength: promptText.length,
hasImageThumb: !!scope.querySelector('img'),
hasVideoThumb: !!scope.querySelector('video'),
hasCanvasThumb: !!scope.querySelector('canvas'),
hasBackgroundThumb,
hasReplaceAction: Array.from(document.querySelectorAll('*')).some(el => {
const t = (el.innerText || '').trim();
return t === '重新上传' || t === '替换';
}),
sendDisabled: sendBtn ? (sendBtn.hasAttribute('disabled') || sendBtn.getAttribute('aria-disabled') === 'true') : null,
sendPresent: !!sendBtn,
};
}''')
async def read_toolbar_model_label(page) -> str:
"""读取当前激活模型 —— 以积分状态栏文字为准(最可靠),其次读工具栏按钮。"""
return await page.evaluate('''() => {
const all = Array.from(document.querySelectorAll('*'));
// 策略1: 读积分状态栏 "沉浸式短片 Seedance 2.0 Fast 按 1 秒 X 积分扣除"
// 这是页面上最权威的当前模型指示器
const statusBar = all.find(el => {
const t = (el.innerText || '').trim();
return t.includes('积分') && t.includes('Seedance') && el.offsetHeight < 60;
});
if (statusBar) {
const t = statusBar.innerText.trim();
const hasFast = t.includes('Fast');
return hasFast ? 'Seedance 2.0 Fast' : 'Seedance 2.0';
}
// 策略2: 读工具栏按钮(不是下拉菜单内的选项)
// 工具栏按钮一般是小按钮,在输入框上方,显示 "2.0" 或 "2.0 fast"
const toolbarBtn = all.find(el => {
const t = (el.innerText || '').trim();
const isLabel = t === '2.0' || t === '2.0 fast' || t === '2.0 Fast';
if (!isLabel) return false;
const r = el.getBoundingClientRect();
// 工具栏按钮:位置在右侧,高度小
return r.width > 0 && r.height > 0 && r.height < 40 && r.left > 300;
});
if (toolbarBtn) {
const t = toolbarBtn.innerText.trim();
const hasFast = t.toLowerCase().includes('fast');
return hasFast ? 'Seedance 2.0 Fast' : 'Seedance 2.0';
}
return '';
}''')
async def click_extend_button(page) -> bool:
result = await page.evaluate('''([labels]) => {
const all = Array.from(document.querySelectorAll('button, a, div, span'));
const candidates = all.filter(el => {
const text = (el.innerText || '').trim();
if (!labels.includes(text)) return false;
const r = el.getBoundingClientRect();
return r.left > 350 && r.top > 350 && r.width < 160 && r.height < 40 && r.width > 20 && r.height > 8;
});
candidates.sort((a, b) => {
const ra = a.getBoundingClientRect();
const rb = b.getBoundingClientRect();
return Math.abs(ra.top - 548) - Math.abs(rb.top - 548) || (ra.left - rb.left);
});
const el = candidates[0];
if (!el) return 'NOT_FOUND';
el.click();
return 'CLICKED: ' + (el.innerText || '').trim();
}''', [EXTEND_BUTTON_PATTERNS])
print(f" 延长入口: {result}")
if result.startswith('CLICKED'):
return True
fallback = await page.evaluate('''() => {
const media = Array.from(document.querySelectorAll('video, img')).filter(el => {
const r = el.getBoundingClientRect();
return r.left > 300 && r.top > 250 && r.width > 180 && r.height > 120;
}).sort((a, b) => b.getBoundingClientRect().width - a.getBoundingClientRect().width)[0];
if (!media) return null;
const r = media.getBoundingClientRect();
return {
x: Math.round(r.left + r.width * 0.58),
y: Math.round(r.bottom + 30)
};
}''')
if fallback:
try:
await page.mouse.click(fallback['x'], fallback['y'])
print(f" 延长入口坐标兜底: clicked at ({fallback['x']}, {fallback['y']})")
return True
except Exception as e:
print(f" ❌ 延长入口坐标兜底失败: {e}")
return False
async def run_extend(prompt: str, duration: str, dry_run: bool, extend_url: str):
global DEBUG_SCREENSHOTS
DEBUG_SCREENSHOTS = dry_run
print("🚀 Starting Playwright + Chromium (headless)... [EXTEND (续写/延长)]")
print(f"🔗 目标线程: {extend_url}")
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})
cookies = load_and_clean_cookies()
await context.add_cookies(cookies)
page = await context.new_page()
print("🌐 [Step 1] Navigating to extend thread page...")
await goto_with_retry(page, extend_url)
await page.wait_for_timeout(8000)
await screenshot(page, 'extend_1_detail')
print("🔍 [Step 2] Checking page status...")
content = await page.content()
is_logged_in = '小云雀助你' in content or '新对话' in content or 'thread_id' in page.url
if not is_logged_in:
print(" ❌ LOGIN_FAILED_OR_THREAD_NOT_VISIBLE")
await browser.close()
return
print(" ✅ THREAD_PAGE_READY")
print("🪄 [Step 3] Clicking extend button...")
extend_clicked = await click_extend_button(page)
if not extend_clicked:
await screenshot(page, 'extend_3_button_not_found')
await browser.close()
return
# 向后延伸会跳转回 home 对话页,这里等待输入框真正出现
editor_ready = False
for i in range(8):
await page.wait_for_timeout(1500)
editables = await page.locator('div[contenteditable="true"]').count()
if editables > 0:
editor_ready = True
break
print(f" Extend target url: {page.url}")
print(f" Extend editor ready: {editor_ready}")
await screenshot(page, 'extend_3_clicked')
if not editor_ready:
await browser.close()
return
print("⏱️ [Step 4] Selecting duration...")
dur_click_result = await page.evaluate('''() => {
const all = Array.from(document.querySelectorAll('*'));
const btn = all.find(el => {
const text = (el.innerText || '').trim();
const r = el.getBoundingClientRect();
return /^\\d+s$/.test(text) && r.left > 300 && r.height > 5 && r.height < 50;
});
if(btn) {
btn.click();
return 'clicked';
}
return 'not found';
}''')
await page.wait_for_timeout(1500)
await screenshot(page, 'extend_4a_duration_dropdown')
if dur_click_result == 'clicked':
try:
dur_item = page.locator(f'text=/^{duration}$/').locator('visible=true').first
if await dur_item.count() > 0:
await dur_item.click(timeout=3000)
print(f" ✅ 时长选择: {duration}")
except Exception as e:
print(f" ⚠️ 时长选择失败: {e}")
await page.wait_for_timeout(1000)
await screenshot(page, 'extend_4b_duration_selected')
print(f"📝 [Step 5] Injecting prompt: {prompt}")
inject_result = await page.evaluate('''([text]) => {
const all = Array.from(document.querySelectorAll('div[contenteditable="true"]'));
const el = all.find(e => e.getBoundingClientRect().left > 300);
if (!el) return 'FAILED: no contenteditable found';
el.focus();
el.innerText = text;
el.dispatchEvent(new Event('input', { bubbles: true }));
return 'OK: ' + el.innerText.substring(0, 30) + '...';
}''', [prompt])
print(f" Inject: {inject_result}")
await page.wait_for_timeout(1000)
await screenshot(page, 'extend_5_prompt')
if dry_run:
await screenshot(page, 'extend_6_DRY_RUN_FINAL')
editor_state = await collect_editor_state(page)
print("\n✅ EXTEND DRY-RUN 完成!请检查截图 step_extend_6_DRY_RUN_FINAL.png")
print(f"🧪 表单状态: {json.dumps(editor_state, ensure_ascii=False)}")
if editor_state['sendPresent'] and editor_state['sendDisabled']:
print("⚠️ DRY-RUN 告警: 发送按钮仍是禁用态。")
await browser.close()
return
print("🖱️ [Step 6] Clicking send button...")
thread_id = await submit_and_capture_thread(page, 'extend_6_submitted')
if not thread_id:
await browser.close()
return
print(f"🔗 [Step 7] Extend thread_id: {thread_id}")
await open_thread_and_download(page, thread_id, prompt, duration)
await browser.close()
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
ref_video_ready = False
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("🔑 [Step 1] Injecting cookies...")
cookies = load_and_clean_cookies()
await context.add_cookies(cookies)
print(f" ✅ {len(cookies)} cookies injected")
page = await context.new_page()
# === Step 2: 导航 ===
print("🌐 [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("🔍 [Step 3] Checking login status...")
content = await page.content()
# 新 UI: 页面 HTML 中总含 "登录" 文字(在属性中), 改用检测问候语或导航元素
is_logged_in = '小云雀助你' in content or '新对话' in content
if is_logged_in:
print(" ✅ LOGIN_SUCCESS")
else:
print(" ❌ LOGIN_FAILED — 请重新导出 cookies.json!")
await browser.close()
return
# === Step 3.5: 统一走沉浸式短片表单模式,模型由 Step 5 下拉菜单选择 ===
want_fast_mode = "Fast" in model
if False: # 不再走 Agent 卡片模式
print("🎬 [Step 3.5] Selecting 'Seedance 2.0' mode (non-Fast) via quick card...")
# 点击首页底部的 "Seedance 2.0 首发试用" 快捷入口卡片
s2_card_clicked = await page.evaluate('''() => {
const all = Array.from(document.querySelectorAll('*'));
// 匹配卡片文字包含 "Seedance 2.0" 且不含 "Fast",不含 "短剧"
const el = all.find(e => {
const t = (e.innerText || '').trim();
const r = e.getBoundingClientRect();
return (t.includes('Seedance 2.0') || t.includes('Seedance2.0'))
&& !t.includes('Fast') && !t.includes('短剧') && !t.includes('Agent')
&& r.top > 400 && r.width > 50 && r.width < 400 && r.height > 20 && r.height < 120;
});
if (el) { el.click(); return 'CLICKED: ' + el.innerText.trim().substring(0, 40); }
return 'NOT_FOUND';
}''')
print(f" Seedance 2.0 card: {s2_card_clicked}")
await page.wait_for_timeout(3000)
await screenshot(page, '3_5a_mode_dropdown')
if 'NOT_FOUND' in s2_card_clicked:
# 兜底:通过 Agent 模式下拉选择
print(" ⚠️ Card not found, falling back to dropdown...")
mode_btn_pos = await page.evaluate('''() => {
const all = Array.from(document.querySelectorAll('*'));
const el = all.find(e => {
const t = (e.innerText || '').trim();
const r = e.getBoundingClientRect();
return t === 'Agent 模式' && r.left > 300 && r.height < 50 && r.height > 10;
});
if (el) {
const r = el.getBoundingClientRect();
return {x: r.left + r.width/2, y: r.top + r.height/2};
}
return null;
}''')
if mode_btn_pos:
await page.mouse.click(mode_btn_pos['x'], mode_btn_pos['y'])
await page.wait_for_timeout(2000)
# 在下拉中找 Seedance 2.0 非Fast的选项
await page.evaluate('''() => {
const all = Array.from(document.querySelectorAll('*'));
const el = all.find(e => {
const t = (e.innerText || '').trim();
const r = e.getBoundingClientRect();
return (t.includes('Seedance 2.0') || t.includes('Seedance2.0'))
&& !t.includes('Fast') && r.left > 300 && r.height > 20 && r.height < 100;
});
if (el) { el.click(); return true; }
return false;
}''')
await page.wait_for_timeout(3000)
await screenshot(page, '3_5b_mode_selected')
else:
print("🎬 [Step 3.5] Selecting '沉浸式短片' from mode dropdown (Fast mode)...")
mode_btn_pos = await page.evaluate('''() => {
const all = Array.from(document.querySelectorAll('*'));
const el = all.find(e => {
const t = (e.innerText || '').trim();
const r = e.getBoundingClientRect();
return t === 'Agent 模式' && r.left > 300 && r.height < 50 && r.height > 10;
});
if (el) {
const r = el.getBoundingClientRect();
return {x: r.left + r.width/2, y: r.top + r.height/2};
}
return null;
}''')
mode_dropdown_opened = False
if mode_btn_pos:
await page.mouse.click(mode_btn_pos['x'], mode_btn_pos['y'])
mode_dropdown_opened = True
print(" ✅ Agent 模式下拉: clicked")
else:
print(" ⚠️ fail to find Agent mode button")
await page.wait_for_timeout(2000)
await screenshot(page, '3_5a_mode_dropdown')
if mode_dropdown_opened:
immersive_clicked = await page.evaluate('''() => {
const all = Array.from(document.querySelectorAll('*'));
const el = all.find(e => {
const t = (e.innerText || '').trim();
const r = e.getBoundingClientRect();
return t.includes('沉浸式短片') && r.left > 300 && r.height > 30 && r.height < 80;
});
if (el) { el.click(); return true; }
return false;
}''')
if immersive_clicked:
print(" ✅ 沉浸式短片: clicked")
else:
print(" ⚠️ fail to click immersive mode item")
await page.wait_for_timeout(3000)
await screenshot(page, '3_5b_mode_selected')
# === Step 3.6: 上传参考图片 (仅 I2V 模式) ===
if ref_image:
print(f"🖼️ [Step 3.6] Uploading reference image: {os.path.basename(ref_image)}")
# 点击输入区域的 "+" 或 "上传参考素材" 按钮
plus_clicked = False
try:
plus_result = await page.evaluate('''() => {
const svgs = Array.from(document.querySelectorAll('svg.lucide-plus'));
let target = svgs.find(svg => {
const r = svg.getBoundingClientRect();
return r.top > 300 && r.left > 300;
});
if (!target) {
const all = Array.from(document.querySelectorAll('button[title="上传参考素材"], button[title*="添加"]'));
target = all.find(el => el.getBoundingClientRect().left > 300);
}
if (target) {
const btn = target.closest('button') || target.parentElement;
if (btn) btn.click();
else target.click();
return 'OK_EVAL';
}
return 'NOT_FOUND';
}''')
print(f" + 按钮: JS eval -> {plus_result}")
plus_clicked = plus_result.startswith('OK')
except Exception as e:
print(f" + 按钮: script_fail ({e})")
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 || '').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" ✅ 图片已选择: {os.path.basename(ref_image)}")
# 等待图片上传完成 (检测缩略图出现)
print(" ⏳ 等待图片上传...")
for wait_i in range(30):
await page.wait_for_timeout(3000)
has_image = await page.evaluate('''() => {
// 新 UI: 检查 contenteditable 附近是否有 img
const editable = document.querySelector('div[contenteditable="true"]');
if (editable) {
// 向上查找父容器中的 img
const parent = editable.closest('div[class]') || editable.parentElement;
if (parent && parent.querySelector('img')) return true;
}
// 兜底: 查找是否有内容为“图片1”或类似的元素(缩略图标题)
const all = Array.from(document.querySelectorAll('*'));
const hasPicThumb = all.some(el => {
const t = (el.innerText || '').trim();
return t === '图片1' || t === '视频1' || (el.tagName === 'IMG' && el.src.includes('tos'));
});
return hasPicThumb;
}''')
if has_image:
print(f" ✅ 图片上传完成 (elapsed: {(wait_i+1)*3}s)")
break
if wait_i > 0 and wait_i % 5 == 0:
print(f" ⏳ 等待中... ({(wait_i+1)*3}s)")
# 关闭弹出菜单 (用 Escape 键,不用 mouse.click 避免误触链接)
await page.keyboard.press('Escape')
except Exception as e:
print(f" ❌ 图片上传失败: {e}")
await page.wait_for_timeout(2000)
await screenshot(page, '3_6_image_uploaded')
# === Step 4: 已在 Step 3.5 中选择了沉浸式短片模式,跳过 ===
# === Step 5: 选模型 (增强版:强制等待 + 最多3次重试确认) ===
print(f"🤖 [Step 5] Selecting model: {model}...")
want_fast = "Fast" in model
# Seedance 2.0 非Fast模式已通过 Step 3.5 点击卡片进入 Agent 对话模式,该模式没有模型下拉,直接跳过
# 检测当前是否进入了 Agent 对话模式(查找“自动”按钮或“与综合助手对话”提示)
is_agent_mode = await page.evaluate('''() => {
const all = Array.from(document.querySelectorAll('*'));
return all.some(el => {
const t = (el.innerText || '').trim();
return (t === '\u81ea\u52a8' || t.includes('\u4e0e\u7efc\u5408\u52a9\u624b\u5bf9\u8bdd'))
&& el.getBoundingClientRect().width > 0;
});
}''')
skip_model_select = is_agent_mode and not want_fast_mode
if skip_model_select:
print(" ⏭️ 检测到 Agent 对话模式,跳过模型/时长选择(将在 prompt 中指定)")
async def try_select_model_once():
"""点击工具栏模型按钮并在下拉中选择目标,返回是否点击成功"""
# 策略:精确匹配工具栏上的模型小按钮(文字为 '2.0 fast' 或 '2.0',宽度较小,位于工具栏区域)
clicked = await page.evaluate('''() => {
const items = Array.from(document.querySelectorAll('div, span, button'));
// 策略1: 精确匹配工具栏小按钮 (text 是 '2.0 fast' 或 '2.0', 宽度<150, 高度<40)
let btn = items.find(el => {
const text = (el.innerText || '').trim().toLowerCase();
const r = el.getBoundingClientRect();
return (text === '2.0 fast' || text === '2.0')
&& r.width > 20 && r.width < 150 && r.height > 5 && r.height < 40
&& r.left > 300;
});
// 策略2: 查找包含下拉箭头的模型按钮
if (!btn) {
btn = items.find(el => {
const text = (el.innerText || '').trim();
const r = el.getBoundingClientRect();
return (text.includes('2.0') && !text.includes('\u6c89\u6d78') && !text.includes('\u5168\u80fd'))
&& r.width > 20 && r.width < 200 && r.height > 5 && r.height < 50
&& r.left > 300 && r.top > 400;
});
}
if (btn) {
const r = btn.getBoundingClientRect();
return JSON.stringify({text: btn.innerText.trim(), x: r.left + r.width/2, y: r.top + r.height/2});
}
return 'NOT_FOUND';
}''')
if 'NOT_FOUND' in clicked:
print(f" Model btn click: NOT_FOUND")
return False
import json as _json
btn_info = _json.loads(clicked)
print(f" Model btn found: '{btn_info['text']}' at ({btn_info['x']:.0f}, {btn_info['y']:.0f})")
# 用 Playwright 原生 mouse.click 点击(比 JS click 更可靠)
await page.mouse.click(btn_info['x'], btn_info['y'])
print(f" Model btn click: OPENED via mouse.click")
clicked = 'OPENED: ' + btn_info['text']
print(f" Model btn click: {clicked}")
if 'NOT_FOUND' in clicked:
return False
await page.wait_for_timeout(2000) # 等下拉完全展开
await screenshot(page, '5_dropdown_open') # 截图看下拉是否打开
# 在下拉菜单中找到目标模型选项并点击
# 下拉菜单标题是 "模型选择",包含 Seedance 2.0 Fast / Seedance 2.0 / Seedance 1.5 三个选项
selected = await page.evaluate('''([wantFast]) => {
const targetText = wantFast ? 'Seedance 2.0 Fast' : 'Seedance 2.0';
const all = Array.from(document.querySelectorAll('div, span, p, li'));
// 策略1: 找叶子 SPAN 的 innerText 精确等于目标的元素
let el = all.find(e => {
const t = (e.innerText || '').trim();
if (t !== targetText) return false;
const r = e.getBoundingClientRect();
return r.width > 0 && r.top > 300 && r.top < 750 && r.left > 300
&& e.childElementCount <= 1 && r.height < 30;
});
// 策略2: 放宽 - 找包含目标文本但不含对手模型名的小元素
if (!el) {
el = all.find(e => {
const t = (e.innerText || '').trim();
if (!t.startsWith('Seedance 2.0')) return false;
const hasFast = t.includes('Fast');
if (wantFast !== hasFast) return false;
const r = e.getBoundingClientRect();
return r.left > 300 && r.top > 300 && r.top < 750 && r.height < 40;
});
}
if (!el) {
const debug = all.filter(e => {
const t = (e.textContent || '').trim();
const r = e.getBoundingClientRect();
return t.includes('Seedance') && r.left > 300 && r.top < 700;
}).slice(0, 8).map(e => {
const r = e.getBoundingClientRect();
return '"' + e.textContent.trim().substring(0, 30) + '" y=' + Math.round(r.top);
}).join('; ');
return 'NOT_FOUND_IN_DROPDOWN. Debug: ' + debug;
}
const r = el.getBoundingClientRect();
return JSON.stringify({found: true, text: el.textContent.trim().substring(0, 30), x: r.left + r.width/2, y: r.top + r.height/2});
}''', [want_fast])
if 'NOT_FOUND' in selected:
print(f" Model dropdown select: {selected}")
return False
option_info = _json.loads(selected)
if option_info.get('found'):
print(f" Model dropdown option: '{option_info['text']}' at ({option_info['x']:.0f}, {option_info['y']:.0f})")
await page.mouse.click(option_info['x'], option_info['y'])
print(f" Model dropdown select: SELECTED via mouse.click")
await page.wait_for_timeout(1000)
return True
return False
await screenshot(page, '5a_model_dropdown')
# 最多尝试3次,每次选完等待500ms再验证工具栏标签
model_confirmed = skip_model_select # 如果跳过则直接认为确认
for attempt in range(0 if skip_model_select else 3):
ok = await try_select_model_once()
await page.wait_for_timeout(800)
current_label = await read_toolbar_model_label(page)
print(f" [Attempt {attempt+1}] toolbar label after select: '{current_label}'")
if current_label:
current_is_fast = 'Fast' in current_label
if current_is_fast == want_fast:
print(f" ✅ 模型确认: {current_label}")
model_confirmed = True
break
else:
print(f" ⚠️ 标签不符 (want_fast={want_fast}, got '{current_label}'),再试...")
await page.wait_for_timeout(500)
else:
print(f" ⚠️ 无法读取工具栏标签,再试...")
await page.wait_for_timeout(500)
if not model_confirmed:
print(f" ❌ 经过3次尝试仍无法切换到目标模型,继续使用当前模型提交")
await screenshot(page, '5b_model_selected')
# === Step 6: 上传参考视频 (仅 V2V 模式) ===
if ref_video:
print(f"📎 [Step 6] Uploading reference video: {os.path.basename(ref_video)}")
# 预检查并缩放视频
actual_video_path = await check_and_resize_video(ref_video)
is_temp = actual_video_path != ref_video
try:
# 6a: 点击工具栏的“参考素材”按钮 → 弹出面板
panel_opened = await open_reference_material_panel(page)
if not panel_opened:
print(" ❌ 未能打开参考素材面板")
await screenshot(page, '6a_ref_panel_failed')
await browser.close()
return
await page.wait_for_timeout(2000)
await screenshot(page, '6a_ref_panel')
# 6b: 上传本地视频
uploaded = await upload_reference_media(page, actual_video_path, 'video')
if not uploaded:
print(" ❌ 未能触发本地视频上传")
await screenshot(page, '6b_ref_upload_trigger_failed')
await browser.close()
return
print(f" ✅ 文件已选择: {os.path.basename(actual_video_path)}")
await page.wait_for_timeout(1500)
confirm_clicked = await confirm_reference_media(page)
if not confirm_clicked:
print(" ❌ 参考视频已选择,但确认按钮没有成功点击")
await screenshot(page, '6b_ref_confirm_failed')
await browser.close()
return
upload_ready = await wait_for_reference_media_ready(page, 'video')
if not upload_ready:
print(" ❌ 参考视频在等待窗口内没有进入已挂载状态")
await screenshot(page, '6b_ref_upload_timeout')
await browser.close()
return
ref_video_ready = True
# 关闭参考面板
await page.keyboard.press('Escape')
await page.wait_for_timeout(1000)
await screenshot(page, '6b_ref_uploaded')
finally:
if is_temp and os.path.exists(actual_video_path):
try:
os.remove(actual_video_path)
print(f" 🧹 已清理临时缩放视频: {actual_video_path}")
except:
pass
# === Step 7: 选时长及比例 ===
step7_label = '7' if ref_video else '6'
# 将比例合并至 Prompt 的方案 (因为新 UI 消失了原生组件),确保最终一定生效
if ratio and ratio not in prompt:
prompt = f"[{ratio}] {prompt}"
if skip_model_select:
# Agent 对话模式:时长和比例在 prompt 中指定
print(f"⏱️ [Step {step7_label}] Agent 模式,时长和比例将在 prompt 中指定")
if duration and duration not in prompt:
prompt = f"[{duration}] {prompt}"
else:
print(f"⏱️ [Step {step7_label}] Selecting duration: {duration} (ratio fallback via prompt)...")
if not skip_model_select:
dur_click_result = await page.evaluate('''() => {
const all = Array.from(document.querySelectorAll('*'));
const btn = all.find(el => {
const text = (el.innerText || '').trim();
const r = el.getBoundingClientRect();
return /^\\d+s$/.test(text) && r.left > 300 && r.height > 5 && r.height < 50;
});
if(btn) {
btn.click();
return 'clicked';
}
return 'not found';
}''')
await page.wait_for_timeout(1500)
await screenshot(page, f'{step7_label}a_duration_dropdown')
if dur_click_result == 'clicked':
try:
# 尝试选具体的时长
dur_item = page.locator(f'text=/^{duration}$/').locator('visible=true').first
if await dur_item.count() > 0:
await dur_item.click(timeout=3000)
print(f" ✅ 时长选择: {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 all = Array.from(document.querySelectorAll('div[contenteditable="true"]'));
const el = all.find(e => e.getBoundingClientRect().left > 300);
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')
# === 模型二次确认:仅对 Fast 模式有效,Seedance 2.0 已通过卡片确认无需重选 ===
if not want_fast_mode:
print("⏭️ [Model Re-confirm] Seedance 2.0 Agent 模式,跳过模型二次确认")
else:
# Fast 模式才需要二次确认
print(f"🤖 [Model Re-confirm] Re-selecting model after prompt: {model}...")
model_reconfirmed = False
for attempt in range(5):
ok = await try_select_model_once()
await page.wait_for_timeout(800)
current_label = await read_toolbar_model_label(page)
print(f" [Re-confirm attempt {attempt+1}] toolbar label: '{current_label}'")
if current_label:
current_is_fast = 'Fast' in current_label
if current_is_fast == want_fast:
print(f" ✅ 模型最终确认: {current_label}")
model_reconfirmed = True
break
else:
print(f" ⚠️ 仍不符 (want_fast={want_fast}, got '{current_label}'),再试...")
await page.wait_for_timeout(500)
if not model_reconfirmed:
print(f" ❌ 模型二次确认失败,将以当前模型提交")
await screenshot(page, f'model_reconfirm')
# === 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 || '').trim();
// 新 UI: 顶部显示 "沉浸式短片 Seedance 2.0 Fast 按 1 秒 3 积分扣除"
return t && t.includes('积分') && el.offsetHeight < 50;
});
return info ? info.innerText.trim() : 'NOT_FOUND';
}''')
editor_state = await collect_editor_state(page)
ref_state_ok = True
if ref_video:
ref_state_ok = (
ref_video_ready or
editor_state['hasVideoThumb'] or
editor_state['hasImageThumb'] or
editor_state['hasCanvasThumb'] or
editor_state['hasBackgroundThumb'] or
editor_state['hasReplaceAction']
)
print(f"\n✅ DRY-RUN 完成!请检查截图 step_8_DRY_RUN_FINAL.png")
print(f"📊 底部状态栏: {status_text}")
print(f"🧪 表单状态: {json.dumps(editor_state, ensure_ascii=False)}")
if ref_video and not ref_state_ok:
print("❌ DRY-RUN 失败: V2V 参考视频没有出现在编辑器区域,当前流程还没跑通。")
await browser.close()
return
if editor_state['sendPresent'] and editor_state['sendDisabled']:
print("⚠️ DRY-RUN 告警: 发送按钮仍是禁用态,页面可能还没接受当前表单。")
print(f"\n确认无误后,去掉 --dry-run 参数重新运行即可提交任务。")
await browser.close()
return
print("🖱️ [Step 8] Clicking send button (arrow)...")
thread_id = await submit_and_capture_thread(page, '8_submitted')
if not thread_id:
await browser.close()
return
print(f"🔗 [Step 9] Navigating to thread detail page...")
await open_thread_and_download(page, thread_id, prompt, duration)
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("--extend-url", type=str, default=None, help="Existing thread URL for extend/continue 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:
if args.extend_url:
asyncio.run(run_extend(args.prompt, args.duration, args.dry_run, args.extend_url))
else:
asyncio.run(run(args.prompt, args.duration, args.ratio, args.model, args.dry_run, args.ref_video, args.ref_image))
import asyncio
import json
import os
import html
import argparse
import subprocess
import re
from playwright.async_api import async_playwright
def load_and_clean_cookies(cookies_file):
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 poll_thread(thread_id, cookies_file, output_dir):
print(f"🕵️ Polling status for thread: {thread_id}")
detail_url = f"https://xyq.jianying.com/home?tab_name=integrated-agent&thread_id={thread_id}"
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 = load_and_clean_cookies(cookies_file)
await context.add_cookies(cookies)
page = await context.new_page()
print(f"🔗 Navigating to: {detail_url}")
await page.goto(detail_url, wait_until='domcontentloaded')
await page.wait_for_timeout(5000)
mp4_url = None
for i in range(10): # Just poll 10 times (50s) to check if it's already done
mp4_url = await page.evaluate('''() => {
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;
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"✅ Found MP4: {mp4_url[:100]}...")
break
print(".", end="", flush=True)
await page.wait_for_timeout(5000)
if i % 3 == 0:
await page.reload(wait_until='domcontentloaded')
await page.wait_for_timeout(3000)
if mp4_url:
filename = f"re-downloaded_{thread_id}.mp4"
filepath = os.path.join(output_dir, filename)
print(f"📥 Downloading to {filepath}...")
result = subprocess.run(
['curl', '-L', '-o', filepath, '-s', '-w', '%{http_code}', mp4_url],
capture_output=True, text=True, timeout=120
)
if os.path.exists(filepath) and os.path.getsize(filepath) > 10000:
print(f"✨ Success: {filepath}")
return filepath
else:
print(f"❌ Download failed (HTTP {result.stdout.strip()})")
else:
print("\n❌ Video still not ready or not found.")
await browser.close()
return None
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--thread-id", type=str, required=True)
parser.add_argument("--cookies", type=str, default="cookies.json")
parser.add_argument("--output-dir", type=str, default=".")
args = parser.parse_args()
asyncio.run(poll_thread(args.thread_id, args.cookies, args.output_dir))
#!/usr/bin/env python3
"""重新提交 Seedance 视频并可靠地捕获 thread_id"""
import asyncio, json, os, re, sys
from playwright.async_api import async_playwright
OUTPUT_DIR = '/root/.openclaw/workspace/video_output'
REF_IMAGE = '/root/.openclaw/media/inbound/file_26---513d53c7-fbbe-4097-af52-3561d49ca170.jpg'
PROMPT = "变身特效,画面从@图片1的休闲牛仔夹克造型开始,光影闪烁中衣服逐渐变为酒红色丝绸吊带晚礼服,妆容变得精致浓艳,眼神妩媚迷人,散发妖媚气质,电影级光影,慢动作变身特效,强烈的反差感"
async def main():
with open('cookies.json') as f:
cookies_data = json.load(f)
valid_same_site = {'Strict', 'Lax', 'None'}
filtered_cookies = []
for c in cookies_data:
cookie = dict(c)
if 'sameSite' in cookie and cookie['sameSite'] not in valid_same_site:
cookie['sameSite'] = 'Lax'
filtered_cookies.append(cookie)
thread_id = None
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
await context.add_cookies(filtered_cookies)
page = await context.new_page()
# Intercept ALL responses to find thread_id
async def handle_response(response):
nonlocal thread_id
if thread_id:
return
try:
text = await response.text()
except:
return
m = re.search(r'"thread_id"\s*:\s*"([0-9a-f-]{36})"', text)
if m:
thread_id = m.group(1)
print(f"🎯 Found thread_id in response: {thread_id}")
page.on('response', handle_response)
print("🔑 Injecting cookies...")
await page.goto("https://xyq.jianying.com/home?tab_name=home&source=home&mode=create&feature=seedance2.0", wait_until="networkidle", timeout=30000)
await page.wait_for_timeout(3000)
print("✅ Logged in")
# Select Seedance 2.0 mode
print("🎬 Selecting Seedance 2.0...")
seedance_card = page.locator('text=Seedance 2.0').first
if await seedance_card.count() > 0:
await seedance_card.click()
await page.wait_for_timeout(2000)
# Upload reference image
print("🖼️ Uploading reference image...")
file_input = page.locator('input[type="file"]').first
if await file_input.count() > 0:
await file_input.set_input_files(REF_IMAGE)
else:
# Try to find upload button
upload_btn = page.locator('text=本地上传').first
if await upload_btn.count() > 0:
await upload_btn.click()
await page.wait_for_timeout(1000)
file_input = page.locator('input[type="file"]').first
await file_input.set_input_files(REF_IMAGE)
await page.wait_for_timeout(5000)
print("✅ Image uploaded")
# Select model
print("🤖 Selecting model...")
model_btn = page.locator('text=Seedance 2.0 Fast').first
if await model_btn.count() == 0:
model_btn = page.locator('text=Seedance 2.0').first
if await model_btn.count() > 0:
await model_btn.click()
await page.wait_for_timeout(1000)
# Select Seedance 2.0 (not Fast)
model_option = page.locator('text=Seedance 2.0').nth(1)
if await model_option.count() > 0:
await model_option.click()
await page.wait_for_timeout(1000)
# Select duration and ratio
print("⏱️ Selecting duration and ratio...")
duration_btn = page.locator('text=5s').first
if await duration_btn.count() > 0:
await duration_btn.click()
await page.wait_for_timeout(1000)
ratio_btn = page.locator('text=横屏').first
if await ratio_btn.count() > 0:
await ratio_btn.click()
await page.wait_for_timeout(1000)
# Enter prompt
print("📝 Entering prompt...")
prompt_input = page.locator('textarea').first
if await prompt_input.count() > 0:
await prompt_input.fill(PROMPT)
await page.wait_for_timeout(1000)
# Click send
print("🖱️ Clicking send...")
submit_btn = page.locator('button:has(svg.lucide-arrow-up)').first
if await submit_btn.count() == 0:
submit_btn = page.locator('button').filter(has_text="发送").first
if await submit_btn.count() > 0:
await submit_btn.click()
print("✅ Submitted!")
else:
print("❌ Could not find send button")
await page.screenshot(path='submit_debug.png')
await browser.close()
return
# Wait for thread_id
print("⏳ Waiting for thread_id (up to 30s)...")
for i in range(15):
await page.wait_for_timeout(2000)
if thread_id:
break
# Check URL
current_url = page.url
m = re.search(r'thread_id=([0-9a-f-]{36})', current_url)
if m:
thread_id = m.group(1)
print(f"🎯 Found thread_id in URL: {thread_id}")
break
if not thread_id:
# Check page content
html = await page.content()
m = re.search(r'thread_id["\s:=]+([0-9a-f-]{36})', html)
if m:
thread_id = m.group(1)
print(f"🎯 Found thread_id in HTML: {thread_id}")
if not thread_id:
print(f"❌ Could not get thread_id. Current URL: {page.url}")
await page.screenshot(path='submit_failed2.png')
await browser.close()
return thread_id
if __name__ == '__main__':
tid = asyncio.run(main())
if tid:
print(f"\n✅ thread_id: {tid}")
else:
print("\n❌ Failed to get thread_id")
sys.exit(1)
Related skills
How it compares
Use jianying-video-gen when you already rely on Jianying Seedance; pick API-native video skills for platforms without browser cookie auth.
FAQ
What modes does jianying-video-gen support?
jianying-video-gen supports four Seedance 2.0 modes on Jianying: text-to-video (T2V), image-to-video (I2V), reference video-to-video (V2V), and Extend to lengthen existing clips. Each mode runs through scripts/jianying_worker.py.
What setup does jianying-video-gen require?
jianying-video-gen requires Python 3.9+, playwright with Chromium installed, and a cookies.json Jianying login export. Run pip install playwright && playwright install chromium before invoking the worker script with --cookies and --output-dir.