diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b5dfa7..0b80513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 支持 ChatGPT 文本生成适配器 - 支持 zAI 文本生成适配器 - 支持 DeepSeek 文本生成适配器 + - 支持 Sora 视频生成适配器 ### 🔄 Changed - **适配器实现更改** diff --git a/README.md b/README.md index 953a9a9..cbea4d6 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ | [**ZenMux**](https://zenmux.ai/) | ✅ | ❌ | 🚫 | | [**ChatGPT**](https://chatgpt.com/) | ✅ | ✅ | 🚫 | | [**DeepSeek**](https://chat.deepseek.com/) | ✅ | 🚫 | 🚫 | +| [**Sora**](https://sora.chatgpt.com/) | 🚫 | 🚫 | ✅ | | 待续... | - | - | - | > [!NOTE] diff --git a/src/backend/adapter/sora.js b/src/backend/adapter/sora.js new file mode 100644 index 0000000..2f185b3 --- /dev/null +++ b/src/backend/adapter/sora.js @@ -0,0 +1,249 @@ +/** + * @fileoverview Sora 视频生成适配器 + */ + +import { + sleep, + safeClick, + uploadFilesViaChooser +} from '../engine/utils.js'; +import { + fillPrompt, + normalizePageError, + moveMouseAway, + waitForInput, + gotoWithCheck, + useContextDownload +} from '../utils/index.js'; +import { logger } from '../../utils/logger.js'; + +// --- 配置常量 --- +const TARGET_URL = 'https://sora.chatgpt.com/profile'; +const INPUT_SELECTOR = 'textarea'; + +/** + * 执行视频生成任务 + * @param {object} context - 浏览器上下文 { page, config } + * @param {string} prompt - 提示词 + * @param {string[]} imgPaths - 图片路径数组 (只使用第一张) + * @param {string} [modelId] - 模型 ID (此适配器未使用) + * @param {object} [meta={}] - 日志元数据 + * @returns {Promise<{video?: string, error?: string}>} + */ +async function generate(context, prompt, imgPaths, modelId, meta = {}) { + const { page } = context; + + // 只使用第一张图片 + const singleImgPath = imgPaths && imgPaths.length > 0 ? [imgPaths[0]] : []; + if (imgPaths && imgPaths.length > 1) { + logger.warn('适配器', `Sora 只支持一张图片,已丢弃 ${imgPaths.length - 1} 张`, meta); + } + + // 用于存储任务 ID 和视频 URL + let taskId = null; + let videoUrl = null; + + try { + logger.info('适配器', '开启新会话...', meta); + await gotoWithCheck(page, TARGET_URL); + + // 1. 等待输入框加载 + await waitForInput(page, INPUT_SELECTOR, { click: false }); + await sleep(1500, 2500); + + // 2. 上传图片 (如果有) + if (singleImgPath.length > 0) { + logger.debug('适配器', '点击上传文件按钮...', meta); + const attachBtn = page.getByRole('button', { name: 'Attach media' }); + + await uploadFilesViaChooser(page, attachBtn, singleImgPath, { + uploadValidator: (response) => { + const url = response.url(); + if (response.status() === 200 && url.includes('project_y/file/upload')) { + logger.info('适配器', '图片上传完成', meta); + return true; + } + return false; + } + }); + + await sleep(1000, 2000); + } + + // 3. 填写提示词 + await safeClick(page, INPUT_SELECTOR, { bias: 'input' }); + await fillPrompt(page, INPUT_SELECTOR, prompt, meta); + await sleep(500, 1000); + + // 4. 提前设置响应监听器 (drafts 接口) + // 因为 drafts 请求在 pending/v2 检测到任务消失后立即出现,需要提前监听 + let draftsResponsePromise = null; + const startDraftsListener = () => { + draftsResponsePromise = page.waitForResponse(async (response) => { + const url = response.url(); + if (!url.includes('project_y/profile/drafts')) return false; + if (response.request().method() !== 'GET') return false; + if (response.status() !== 200) return false; + return true; + }, { timeout: 600000 }); // 10 分钟超时 + }; + + // 5. 点击 Create video 按钮并监听 nf/create 请求 + logger.debug('适配器', '点击创建视频...', meta); + const createBtn = page.getByRole('button', { name: 'Create video' }); + + // 设置 create 请求监听 + const createResponsePromise = page.waitForResponse(async (response) => { + const url = response.url(); + if (!url.includes('nf/create')) return false; + if (response.request().method() !== 'POST') return false; + if (response.status() !== 200) return false; + return true; + }, { timeout: 60000 }); + + await safeClick(page, createBtn, { bias: 'button' }); + + // 等待 create 响应 + logger.info('适配器', '等待创建任务...', meta); + const createResponse = await createResponsePromise; + + try { + const createBody = await createResponse.json(); + taskId = createBody.id; + if (!taskId) { + logger.error('适配器', '创建响应中没有 id', meta); + return { error: '创建任务失败:响应中没有 id' }; + } + logger.info('适配器', `任务已创建, id: ${taskId}`, meta); + } catch (e) { + logger.error('适配器', '解析 create 响应失败', { ...meta, error: e.message }); + return { error: '解析创建响应失败' }; + } + + // 6. 启动 drafts 监听器 (提前监听) + startDraftsListener(); + + // 7. 监听 nf/pending/v2 等待任务完成 + logger.info('适配器', '等待视频生成完成...', meta); + + let taskCompleted = false; + const maxWaitTime = 300000; // 5 分钟 + const startTime = Date.now(); + + while (!taskCompleted && (Date.now() - startTime) < maxWaitTime) { + try { + const pendingResponse = await page.waitForResponse(async (response) => { + const url = response.url(); + if (!url.includes('nf/pending/v2')) return false; + if (response.request().method() !== 'GET') return false; + if (response.status() !== 200) return false; + return true; + }, { timeout: 30000 }); + + const pendingBody = await pendingResponse.json(); + + // 检查任务是否还在列表中 + const taskInList = pendingBody.find(item => item.id === taskId); + + if (taskInList) { + const status = taskInList.status; + logger.debug('适配器', `任务状态: ${status}`, meta); + // preprocessing, queued, running, processing 都表示进行中 + } else { + // 任务不在列表中,说明已完成 + logger.info('适配器', '任务已完成,等待获取视频链接...', meta); + taskCompleted = true; + } + } catch (e) { + // 超时重试 + if (e.name === 'TimeoutError') { + logger.debug('适配器', '等待 pending 响应超时,继续等待...', meta); + } else { + throw e; + } + } + } + + if (!taskCompleted) { + logger.error('适配器', '等待视频生成超时 (5分钟)', meta); + return { error: '等待视频生成超时 (5分钟)' }; + } + + // 8. 获取 drafts 响应中的视频 URL + logger.debug('适配器', '获取视频链接...', meta); + + try { + const draftsResponse = await draftsResponsePromise; + const draftsBody = await draftsResponse.json(); + + // 在 items 数组中查找 task_id 匹配的项目 + const items = draftsBody.items || draftsBody; + const targetItem = (Array.isArray(items) ? items : []).find( + item => item.task_id === taskId + ); + + if (!targetItem) { + logger.error('适配器', '未找到匹配的视频任务', meta); + return { error: '未找到匹配的视频任务' }; + } + + videoUrl = targetItem.url; + if (!videoUrl) { + logger.error('适配器', '视频项目中没有 url', meta); + return { error: '视频项目中没有 url' }; + } + + logger.info('适配器', '已获取视频链接', meta); + } catch (e) { + logger.error('适配器', '获取视频链接失败', { ...meta, error: e.message }); + return { error: `获取视频链接失败: ${e.message}` }; + } + + // 9. 下载视频并转为 base64 + logger.info('适配器', '正在下载视频...', meta); + const downloadResult = await useContextDownload(videoUrl, page); + + if (downloadResult.error) { + logger.error('适配器', downloadResult.error, meta); + return downloadResult; + } + + logger.info('适配器', '视频生成完成,任务完成', meta); + return { image: downloadResult.image }; // 复用 image 字段存储 base64 + + } catch (err) { + // 顶层错误处理 + const pageError = normalizePageError(err, meta); + if (pageError) return pageError; + + logger.error('适配器', '生成任务失败', { ...meta, error: err.message }); + return { error: `生成任务失败: ${err.message}` }; + } finally { + // 任务结束,将鼠标移至安全区域 + await moveMouseAway(page); + } +} + +/** + * 适配器 manifest + */ +export const manifest = { + id: 'sora', + displayName: 'Sora (视频生成)', + + // 入口 URL + getTargetUrl(config, workerConfig) { + return TARGET_URL; + }, + + // 模型列表 + models: [ + { id: 'sora-2', imagePolicy: 'optional' } + ], + + // 无需导航处理器 + navigationHandlers: [], + + // 核心视频生成方法 + generate +};