From b06aa2bbfc03cddfc6bd990a2a1018c8539f828a Mon Sep 17 00:00:00 2001 From: maxczc Date: Sat, 13 Dec 2025 12:21:10 +0800 Subject: [PATCH] Initial commit --- FEATURES.md | 132 ++++ README.md | 174 ++++++ __pycache__/sora_node.cpython-312.pyc | Bin 0 -> 32934 bytes __pycache__/sora_node.cpython-313.pyc | Bin 0 -> 13335 bytes quick_test.py | 99 +++ sora_node.py | 847 ++++++++++++++++++++++++++ test_sora_node.py | 248 ++++++++ 7 files changed, 1500 insertions(+) create mode 100644 FEATURES.md create mode 100644 README.md create mode 100644 __pycache__/sora_node.cpython-312.pyc create mode 100644 __pycache__/sora_node.cpython-313.pyc create mode 100644 quick_test.py create mode 100644 sora_node.py create mode 100644 test_sora_node.py diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..4bc3056 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,132 @@ +# Sora 自定义节点功能说明 + +## 已实现的功能 + +### ✅ 1. Text-to-Video(文本生成视频) +**节点名称**: `SoraTextToVideo` + +从文本提示生成视频,支持完整的 Sora 参数配置。 + +**主要参数**: +- `prompt`: 视频描述文本 +- `negative_prompt`: 负面提示 +- `duration_seconds`: 视频时长 +- `aspect_ratio`: 宽高比 +- `fps`: 帧率 +- `guidance_scale`: 引导强度 +- `seed`: 随机种子 + +### ✅ 2. Image-to-Video(图像生成视频) +**节点名称**: `SoraImageToVideo` + +从图像生成视频,完全支持 ComfyUI 的图像输入格式。 + +**支持的图像输入格式**: +- ✅ PIL Image 对象 +- ✅ Numpy 数组(自动处理各种形状) +- ✅ 文件路径(PNG, JPG, WebP) +- ✅ Base64 编码字符串 + +**主要参数**: +- `image`: ComfyUI 图像输入(必需) +- `prompt`: 视频描述(必需) +- 其他参数与 Text-to-Video 相同 + +### ✅ 3. Video-to-Video(视频编辑/扩展) +**节点名称**: `SoraVideoToVideo` + +对现有视频进行编辑、扩展或风格转换。 + +**支持的操作类型**: +- `extend`: 扩展视频时长 +- `edit`: 根据提示编辑视频内容 +- `inpaint`: 视频修复/填充 +- `style_transfer`: 风格迁移 + +**主要参数**: +- `video`: 视频文件路径或 base64 字符串(必需) +- `prompt`: 编辑描述(必需) +- `operation`: 操作类型(extend/edit/inpaint/style_transfer) +- `extension_seconds`: 扩展时长(用于 extend 操作) +- 其他参数与其他节点相同 + +## 技术特性 + +### 🔧 文件上传支持 +- 扩展了 `SoraClient` 以支持 `multipart/form-data` 上传 +- 自动处理图像和视频文件的编码和上传 +- 支持多种 MIME 类型 + +### 🖼️ 图像处理 +- 自动格式转换(PIL ↔ Numpy ↔ 文件) +- 自动处理不同的数组形状(batch, channels-first, channels-last) +- 自动归一化(0-1 范围转换为 0-255) +- 自动颜色空间转换(RGBA → RGB) + +### 📹 视频处理 +- 支持多种视频格式(MP4, MOV, AVI, WebM) +- 支持文件路径和 base64 编码 +- 自动 MIME 类型检测 + +### 🔄 轮询机制 +- 所有节点都支持异步任务轮询 +- 可配置的轮询间隔和超时时间 +- 自动状态检测和错误处理 + +### 📥 下载支持 +- 可选的视频下载功能 +- 支持目录或文件路径 +- 自动文件命名(使用 job_id) + +## 使用示例 + +### Text-to-Video +```python +# 在 ComfyUI 中直接使用节点 +# 输入: prompt = "A beautiful sunset over the ocean" +# 输出: job_id, status, result_json +``` + +### Image-to-Video +```python +# 连接 ComfyUI 的图像节点到 SoraImageToVideo +# 输入: image (来自 LoadImage 节点), prompt = "Animate this image" +# 输出: job_id, status, result_json +``` + +### Video-to-Video +```python +# 扩展视频 +# 输入: video = "/path/to/video.mp4", operation = "extend", extension_seconds = 5.0 +# 输出: job_id, status, result_json +``` + +## 兼容性 + +- ✅ 仅使用 Python 标准库(核心功能) +- ✅ 可选依赖:Pillow(PIL)和 numpy(用于更好的图像处理) +- ✅ 完全兼容 ComfyUI 的节点系统 +- ✅ 支持所有 ComfyUI 图像格式 + +## API 兼容性 + +节点设计为与 Sora 兼容的 REST API 配合使用,支持: +- OpenAI Sora API(当可用时) +- 自定义 Sora 兼容后端 +- 可配置的 base_url 和 endpoint + +## 错误处理 + +- ✅ 清晰的错误消息 +- ✅ API 错误自动捕获和报告 +- ✅ 超时保护 +- ✅ 输入验证 + +## 未来可能的扩展 + +- [ ] 批量处理支持 +- [ ] 视频预览功能 +- [ ] 更多视频操作类型 +- [ ] 实时进度回调 +- [ ] 视频质量/分辨率选项 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f7825a --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# Sora Custom Node for ComfyUI + +This repository contains a comprehensive set of ComfyUI custom nodes for interacting +with a Sora-compatible REST API. The nodes support the full range of Sora capabilities: + +- **Text-to-Video**: Generate videos from text prompts +- **Image-to-Video**: Generate videos from images +- **Video-to-Video**: Edit, extend, or transform existing videos + +All nodes use only Python's standard library (with optional PIL/numpy support for image processing). + +## Installation + +1. Clone or copy this folder into the `ComfyUI/custom_nodes/` directory: + ```bash + cd ComfyUI/custom_nodes + git clone sora + ``` +2. Restart ComfyUI so it can load the new nodes. +3. **Optional dependencies** (for better image handling): + + - `Pillow` (PIL): For processing PIL Image objects + - `numpy`: For processing numpy array inputs + + These are optional - the nodes will work with file paths and base64 strings without them. + +## Available Nodes + +### 1. Sora Text-To-Video + +Generates videos from text prompts. + +**Inputs:** + +- `prompt` (required): Text description of the video +- `negative_prompt`: What to avoid in the video +- `duration_seconds`: Video length (default: 5.0) +- `aspect_ratio`: Video aspect ratio (default: "16:9") +- `fps`: Frames per second (default: 24) +- `guidance_scale`: Generation guidance (default: 7.5) +- `seed`: Random seed for reproducibility +- And more... + +### 2. Sora Image-To-Video + +Generates videos from images. Accepts ComfyUI image inputs (PIL Image, numpy arrays, or file paths). + +**Inputs:** + +- `image` (required): Image input (from ComfyUI image nodes) +- `prompt` (required): Text description for video generation +- All the same optional parameters as Text-To-Video + +**Image Input Support:** + +- PIL Image objects +- Numpy arrays (various shapes supported) +- File paths (PNG, JPG, WebP) +- Base64 encoded strings + +### 3. Sora Video-To-Video + +Edits or extends existing videos. Supports multiple operations: + +- **extend**: Extend video duration +- **edit**: Edit video content based on prompt +- **inpaint**: Fill masked regions +- **style_transfer**: Apply style to video + +**Inputs:** + +- `video` (required): Video file path or base64 string +- `prompt` (required): Description of desired changes +- `operation`: Type of video operation (default: "extend") +- `extension_seconds`: For extend operation (default: 5.0) +- All the same optional parameters as other nodes + +## Configuration + +All nodes look for an API key using the following priority: + +1. The `api_key` input field on the node. +2. The `SORA_API_KEY` environment variable. +3. The `OPENAI_API_KEY` environment variable. + +You can also configure: + +- `base_url`: defaults to `https://api.openai.com/v1/sora`. +- `endpoint`: defaults to `/videos`, change it if your backend uses another path. +- `download_path`: optional local directory or file path for saving the video. +- `wait_for_result`: Whether to poll until completion (default: True) +- `poll_interval`: Seconds between status checks (default: 3.0) +- `max_wait_seconds`: Maximum wait time (default: 120.0) + +## Outputs + +All nodes return three strings: + +1. **Job ID**: Unique identifier for the generation job +2. **Status**: Current status (`submitted`, `processing`, `succeeded`, `failed`, etc.) +3. **Result JSON**: Contains the raw API response and downloaded file path (if any) + +## Local Testing + +You can test the custom node locally without running ComfyUI: + +### 1. Basic Functionality Test + +Run the test script to verify node functionality: + +```bash +# Run basic tests (no API calls) +python test_sora_node.py + +# Run full unittest suite +python test_sora_node.py --unittest + +# Test with real API (requires API key) +python test_sora_node.py --test-client --api-key YOUR_KEY +``` + +### 2. Test in ComfyUI + +1. Copy this folder to `ComfyUI/custom_nodes/sora/` +2. Start ComfyUI +3. The node should appear in the node menu under "Sora" category +4. You can test it directly in the ComfyUI interface + +### 3. Mock Testing + +The test script uses mocks to test API interactions without making real network calls. This is useful for: + +- Testing error handling +- Testing response parsing +- Validating node logic + +## Features + +### Full Sora Capability Support + +- ✅ Text-to-Video generation +- ✅ Image-to-Video generation +- ✅ Video-to-Video editing and extension +- ✅ Multiple video operations (extend, edit, inpaint, style transfer) + +### ComfyUI Integration + +- Seamless integration with ComfyUI image nodes +- Supports all ComfyUI image formats (PIL, numpy, file paths) +- Automatic format conversion and handling + +### Robust Error Handling + +- Clear error messages for API failures +- Timeout protection +- Automatic retry logic for polling + +### Flexible Configuration + +- Environment variable support for API keys +- Customizable endpoints and base URLs +- Optional result downloading +- Webhook support for async operations + +## Notes + +- Nodes poll the job until completion when `wait_for_result` is enabled. +- If `download_path` is a folder, videos are saved as `.mp4`. +- The nodes automatically search for video URLs in various response formats: + - `result_url` or `video_url` (direct) + - `outputs[0].url` or `outputs[0].download_url` (nested) + - `videos` array +- Image inputs are automatically converted to the format required by the API. +- Video inputs support file paths and base64 encoded strings. diff --git a/__pycache__/sora_node.cpython-312.pyc b/__pycache__/sora_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..feffac8021133012b6a7f21bb655ca73f2b92f77 GIT binary patch literal 32934 zcmeHw3w#vUdGGA|{oa*!SFh1qi(Y^+4}k#z5{QSuNEjnOltr_G71FLey8^Rb*~E2I zk+>I8Cqa%$Blo6__?gslo5o7)rgB`TZj;{5vP!j?D7T~mKko1LW(l;;CH>vr?>noi2gK>)b3EqfKB`~tplnFqBbOns7*zBq$W_^+Bv)0Bid@w_YI4=|XvkIDqa#;6xhe+@L&hE> z$wM`08Z!5ohb%poA#0Cy$kt;UviI0!ln$Su9&`*jdz=tc)8isOiowDjH{5v`U5{5z zZKoLhGZbS86uqPn%G*;MFcNVZ%T4{3AgIQqaf! zq2S=4Un&U%ME&3q4TeKa^ww~wKX_z>4bah}0a5~dEPX6+5Sp_Cn0wU2TUxK?g#|gKRi76bMD(d2G-R#c!bDEkCh$Ut4EuhZM6(|2{Ub zPsSVkp-?zVN)hRVdmZDC`uhg`k%&*lD_TP*dF8fXUzFGG9mbOS2YJtw7IF z0jfs^AB>!l^(aoteG1-)jR5b}#n@gHMkl+WP!YGPC{HmPAWG-qQl7DfZ18pg_KAymuQ7#&=7jG3{+2&wAPFODy! zWE_kELJcR?J}0lo+TJ=CgwHLud`~bG9D=c6M|bx=x{r900Y>_s_qTO*!xzd7hhfZ# zG~LuRABT}49616n1+H^*UuFg&x7Z)xzwG)XFz2YaEMkaeJ{s%^FJ<_rWpP=Y>Xky` z@`2o3NLCPEF2;wrnVv>bam9cx6Cvl4jmyg8iV)OqT=4?+MLDFDa*iwEPAw)nN%<6= zG3TCOB!Z(TR*E=y@v>&0hSxvf9~=pgPnp-i;Lr=h8n0%de+7qm?O{K3y%Bbh*Fy(| z&lg8wUMp4{*(BTMldI_7W92E zL`LDW4j+!Wa_S-L-3GBE9w4Wvl*Mso^z`V&1C!lfdE~`Mrne_88#wI-@>ZRFQlEji z*zV-DHUjg}Un?$uNeKzT;fswVfzgwu19Eljg{!Z9^U7I+I^7`aE zpZ}dJuYLWeua6DEkQN+C>34qjj~7M2!7b843s1ZH)pJ*Wd*-LFKl{$)>sMbtb7kh6^b`HTz#v2Bk`}rS zWA`3D84W}l=ppE!z0s4y0pF;UBijh?<&*PvvAKGs(hCizAPvVZhiJTtJUfDw3?&u1 zuq)e2z4%3t!8Z~kUzafCH06!412Tz}0Xapb-IUciZ>dRGY9^0QMPDAhFgkN&b|h)p z!)f>2+Fhk~-3F;YYP-GwV@zBI{R`$@c+FfZ1d_>Ve7VS4LkPoeWRkKYe`qC|9*9Y1zzaHw!P8x5IuNzQ1!+ z6tWRqzV9yI@EMmevWs%@#YPn$l7mLfKYNWU;49Z=??vvSR9*K{qsq8)z#xWXuc9bE zs$yVK!BAp3WK5(l4VW`tst7gagW!5NvjxSUao z{!n6dTs?qoxx_uC67vC(eMy<)@-C{9T0uo*Cty)@h?2qIvjI_*U+j-@g%F>nlf_{h z#7*^3AbK>+T#%8GzZ-nCfyaZ9Xm9u!FF$eEXXMo|nG6OG^P0>^$!qotLng0*5EM*F zQeFweBg4zX5nemwKNetuY=l>W(0`QI0FR;}uf*8A>I55%27DS}tX1G#Wq_&bNFdsa z(b-K1@-yy)I#|n{rGa;Ouot`}#4>g2{2_(bJF4n<^8g%E|51-LF3U%EO$ga?Z46_Grpc zbnd>h_dU01Y}*G4%Dh!}MXNt!IBodsmUG+BZa=s8?B2;;N$2vJRo~ZcUNG6l>nEaf zrm87r%4j{a^YqT~=$x?_6INfgcsZ^20|Vy4nYP^gKrMORB=X!FCfioI;@dU$t!BlW za@AIY=1sF|t6S5jgyM@1tAflMNXhsjhU8pdpyG0rHb!B6oPT1(P?Y)3%khJNE~xlq zRaBBp1|+E@n`vAjcE`N(j#3OARY%F{9#alTA5S))0hD%^IFN+YaaCLfpG)RlM>RU? zL-IlSc!`T^;B8T4Ug9q1es0>hc0l?Rvk}DsBHyRTWZzD=^i#~KwW@4xBKMT;aaja$ z3%yuBc|P0<@x#q|V-)b0xB>4H54eRD{pL}9T>mlN$Pg_N^JO%UQ>l2Dy~4btjT@lP zNc9lcKP`uTqkmF=LiQo0rnC8o9F)3r)R^;JUEDZ;6Vnohy;Q8XxRE^3_@wcKOu{7$ zoi{aG0)95Y(lM)lcz7__M;77DxCjF|&(t~s!yFro5#N{*0D>s2Ga9==&bX->iS{?H zy`W%s!((~PQ5eTyxyrV{9lIXLO)HKnLTD*NOyXZ*~h1Ud;qB7Ox`D<8`Yp0mmy3hnbVS z9^r;w;dy#;=_UR;7@r022+ogW>=g+agV|>AUc55MRLNloi0p?s5X20d>s-m%l5xwV za^g5=t{GEYE~^2NBmQE1GQ^eKn<{R)rcyZcW16&rVMt@P`cnKJlArzW#UNE zQ8VvoOgI{+fL%SdZRrrcYPOKO{~#I1AA~&Q8eNZU!B%*#^la(8tu|q+ozl!W|DyOC z#hk5n&er~RU8=C`-00cS=boA`Y)lklwuP(5l`uZrx65w8GRAo(b~-ju^@WDXs-&fs z)7E}q!644o{);OX*O~a~_`Ib$VX6KZB?oLV<*EEq+4E&d&$4;XiiBsybZyepGPZNU zUdolPm|pg3^DE8ViY>F1vjejab7lLJ_Ac&FA7||Q#e$euRl-vBGfIZV#zd>9PtLE| zo>;S;TfKuT>qy#na{CT(#zVhID=^N@2rh!&Xs+K{OMTl*Z!J=MyG0B9n?T+jQ4=GBF;*Us%|%9Wabn# zqll~W33$>7nrHSPvytWqHLA_OUKZ1CqaL|LQ4h;0YE*~g+9}zyIvB4F@Y+ipql{}{ zTvB~eF(3(W*(hRu;^-lxZj*~Tu7g}OcbiL8n!V!ss3Z_Zr8%cx5^&j7`JWh-=AMCq zYz^PnGM?L%um zmuXD{X;b5<={9-)s#+6fDMo*{t$CZW7~&?##rW}Zi5s)Eb(`np*5~J-KJV1eO?O)w z#yn6YwtV&)mwL{qnX$kZUYrdRIU*BU@v8#a&<2vBB`V1R1GE?`dmX6E@*)RvoH=d* zNzN`wa#oP!?wn7xm^yonTcM0K*?W<@=*U?(+2XbVoIIB}vG)zsFA0F#>=!g@2l*HN z#Qb0(nEf)>-kS0$dq$Eg6x;QVl3ezU8*a^O0GIJg9FgQ4amUAaBWHAlm?`6goK}i= z*(>yGSKJ9LoR#DpAXmvp9ZxzylDl)MS7q}NIU>oqa-LfdcMTx;zQkdl66-DQB2RQZ z>B^Dh+#{WkL@$aMSI?Y%|I{aG3GT^)RrGr=zxvKAPXm(m?q{d2eDl8vP?G?1EdjDv zm`141SV1GWgMq`lg3)$>ytaf#LX4k18Iw0Q#^fe_tVWayw*>*hj|5Re(}Jk8fR3%F zaS?l8MNCy;(wp>b7i3Cc7f9-?SWn{$RfvHc#EF^33{lK%p<_x+7gIJiLTa2T*nL1^ zrFmX%J$*P+y8GDsF=jUs6h>JDZ`gxK9z=rcT3-pT92p5RMCjyI$gbiQM+1-ZDp+X` zfz$~iF+jlV!w{L*2LV_Ha5&TlD{=<4QOZGB@v}Xc-9t!lJ_4~7kaYnb-iV&KEYByb zvX5ZEqeyy@;8KtM1QI_I6av{kAijdUVk!0|Hi!fTRKTw@@6D?an~d-ZM7LGP5%T8s zSzV+CAT}Auquj=NG8XTn0Ktgds$v&IQ_vTo8lwTdfQK3}pXQL-jkvTn}Wk_qL?+GhJ^ z54`Q;O1kGu9!iuvlq`8<&id$rqhuoRW#;0x$%p40D^pI-x!q@Xf7$sJ?~C54wq*Ir z`SNv%@^v${$?`4VcW%9$33p5$`0w8N@|B75mD6jI9X@4T}=;p|U3kKRzo zZFa1V0`kgFP6ekPn$dD4t@9;25+ys5B|GP=yTtr^=B#xhPl{!s*0#ygc1mYHV?1pf zXD8|=ypyXr*RpxniiB%L(sj?Ac2&w^KlAwM$0t;ud1_1vkD_aH7apY>W*(f`$rW$U ztUqq(DTikfJoI03ZSi(f{mvTduRZimui~%QX@UP6uWDzd=5NY0$k&wY-k|udV*Rd- zitlcuf&X5qXZIS#_tvO@_vwXU1HUn09ZWc)Um_gN)d17OYBdUqt3Y&w4e`p(a3}!U z(B+s2MOHQ@au;NsK6Oq24jDIVB*ki00ZVk~4`uWSsBeAbk!&uIH-f8VaLe&k3kzdX zuAPg+(a)z9Z~zic7Sn{oQpVhQihw%c+=uFKn_PHFGW?C=bEVhy8JDPpSu2F|u5E4Y ztp|2@_Yxz8Ev;Q`y#N5PDBG|&-Lv;1L4k$E8I&x`SscceDv-<24+%ay@+~0GP(M(% zr1g}iDdjFtc}mg-qpIw(&6!rg6}}ipL0W^H7C9Yqddgl%Vx^6g-hABze%Fg-s{7z} z-F+CjQ+C-_hUq(Hkb!lz;0HPA-Pa+9P8mjTm0eeBR7E%Jc2!+^C8jj2Q`KFzI?^h* zLOP2*twByp+3BIYD1xp)IRZJfb`j^~G$ZOQufQ`>)FurHY1oM$L$ z4sqHLU_)o_JAL10HgQU?pr6WZRRkTNWn|YUfCK%M6c(qVC7V1IR72Km=OKg*ZDT6!BL4!L>bXrwq=j01W#MO@hr zx}*;&iBwagia3;or1%(h#j@FDS)2k0B2zD*H&X!&l}B$Te%DYFdi)bpz?XaHa^Pz& zaoILwgqq5;w3eQyW(@GB0+SijdF7}kt{ISk^=!6rH6!T+N0m>Bbt)~qz@8(w=h9-z z0f`fRUr>9xIkRuqS;zR%y@|tYh_dyDuZWzjPzN^fbEQyi;o$=rQ;G8 zm*B2Z{ayE0{jaRQwTi`IBmn1W*`j{8MT{Z#F%}H8GQBmM+8sr4khtvVkPB}-EnBLY zAD2J7F{6R^jJyL$^|TjKS?_1AZ{p~*ltC{!y z{Ng*m`v+h(19mj;efnv^+D0_gAz8fh*_YpW_LDz-{R`x-k>1y_yNTG5(pO*p#(S^4 znDGvQ3hE?U>%9BgGZ`-dHF@vVm#=>78OZ+Ki+^-w=1($VXiBzv75(n(pMQ7qlUS@p z;Uv;~fAQI?FZ@1O@PM)2kvt|mBvMR|D*P4_<$VTTo0m1bkAquGNBo+HLq}q2f#qfE z30lL;*~6VNz2G@895^EKCjy6ueae`!X<+zBOa&Bzluej}stLn*#s2MEc*VZX?P%SA z;Eg}}z7ihxJ_rTW`#4hi6zr$r!u}o-^mrHkgG+CE|KH#eTMN}kgLyKlv4SiZ%0|QV zFdOa*L?X0Mg?^UxpNuro9fH*{y$@`W!zY?zr3XThkzq8Og;b>Mf_X8G_Dn6buaXVH z1B4|=EpWU7AR{7WvDleFJBYRy5fHyX#DcYv&;q>f@ak29Mv&c(>9K?IS`r70rg;@S zJHYY^kkfb*l)bn0f!2=Qty@4lh$0Cu4~BWo7PPMF*vl)yXg9!X`of{(0T!U2i2rzi z*FsVOVzG1{*t73YHV0lF8s;^KlffH9xsLjW1H3NE`a=kj1$YG*^#Z&U!50MN32ZUf z^16ODJVX)@nUPnK6&bI@I6eb!AQb~9g+nc_KfEt=o+G_Nxpc$9!CsPW!~n^103E?h#Mzr^KOvIvY!(*=ufhMwUqdH2McwdH zTKjnY`N;P*m6r_#^M>+-p?tDo&d~g|tuuz?s%^88ORILybsd=R>Pd9<%y;<{U4HKH z&|FvOvc*1r3}o5MWy|KvmM6-VPdldl$+ER)jpOPG%g33;&{SQouX^LVtmm!^6}Cz7S>Au|Zv#T7Pl*5(D1<;=m;2hoIOV%MCh?uyMder$5poULxEHC0;n zrT*vpUpP8$KqaT|irsmx_H6CEy&_?+m|UN_f4A%(z?!jCwG0#^=i>8MRT6} z7Hp1l-m~84ipSKXCW{h=qKT19hN_gedfwZZ@HS2blHOHgyHZ-myw;u2x+e;zRGij5 zr)^4sO7yAIPbDq%WIbnTbv=gQ8OB~9g%_g^yAOs#u)(}hhlD-!kh&FcQj@`i=mcYk8@ z{!8`y7u?=28J{;!9-7uA-7RAsu;?_pgw@4F-z5XAhASq!o-dnsFHg9af6e}?`xQ6D zJKb^FT|V!wOStPU+Fy2G0Kb)E9Z(NO&w{&z*t$Y?;I-f?JlAu!=edW++Eb8k=jqOg z)l=1DopZ+JDS(+ao!yjlR81b^94k2EiYsV2doXE#kTX6A4NW@Z$egA+W${kz;)oXd zir&c;-upFXW_@z?cJAICoU0?L-^nR=esD+zt(vga-Hadr`{qj9?oEpCZBp&gX%?-D zrPWa>42Qp29sOo?^zX1bQo-s7)<%r^Bd?B7Xdn*O^3qBvkCav}#$zm53G;5Hv}Zq= zkUA;!8@_1 zk)RM1(-Gh1q2X0AwZO8U!9bLY*k^%s#w>zA2t$3xaQlGh@p$7C0CfMAs9ofSG*1gh9SMe;Qu5d;fFiDIcZk(=e8Lp&Yy z&8 zcRlgBVBX_$=Dl`>;yVgeyHV2z?IF1g`X#&mzrbtprYA-Y+ea0!wF$O0F|dg#pk-W) z`WdCT%7$%B8u-&Pc-6yZPd$uY=mR4dZhY#uh})Td`72WZTR>qW)a`O$3dz<`W4>_4 zO}2}g0%pd;IC9EuIqCFyc?&8ryFrNV4nKf9q++j-F`;YZ@K6wU_tCJS1y?xI&MMp~ z=?^h)6f z`)DvR_u~qX_78@|eWCa?(Lfv4N~p|*O(n2lv@Y0B7GAI?C&2iG%y}ig!vzJe?&{v( z(Yc-dHZ&q3V6=(IAL|B!swA>c4^}fF*o7JmERuXs;jTbj_N9a^0+o>%mvp$N2kP?q5|gtNf5HydNee4v*&7$@`}DRvZ|CO9co zL59md19KJfDg;hJ!nM$@dMQ`faGc?@alPj)OwoBjVtvJ(yc$+GL&H&0E8Q_)E8Q0i z1z_z9_0Pa2)&Lzj8jjN8e)yI3T{bfyH9IGP+2!jwGO!Wi(e%Kh)&Vz!k zefQqh?#$ZQxtVl zt;`6tHW+L4M!;+~#6)7~2@rm>^p@G-KB&T_&Jnht;wP^E#JKu!29z$5~Dm0`uqTVeg%OX7&nHi_Mu>}G)K zg{=$%j=*chPYSkMJP9v*LB=ck1|ty^Ycd-e3>}^O4s`c+AKKT}6)Rfu={DsAw%|;2 z4HCl6BrRq0Ost(=#cc~Gs)sr6V`+ulV+QY%>gl%G!-<*>u4pH0x|`Yd_WnfEKCbfq zRCU8l6X(7sp^W zr7D-rC}y{E_je~64saC@q)Mu%baVIY;A%U#;+^DfdL37LA6L8)Zl=58(LxAaH??m1 z@KiHb){?4foYu{(;+i*em941?-*ja5o<#k2u6zfUsrqe2qG>l*wI@}vZ0gXAd}=RO zzTsM#)nmSqmrfypCmQ&3BH8bH3~=QX4DxPkS6nHylSg3G&s z!tfF{IJmf-Q6MNAH?pd@5$?z)WH4sSqpOMA#Z;rttmY-|%(wFhb`@owAZRLY6HC(U zWZold8VIIgdGm#lP?WYu3v%=^u6RM*$xs&!lCfW5+{Aj~F18Rf1vo-4adBJRMGOS3 z7mY7vYHPGGWPwzsxC^wUk`4wAQOLXtXiE$8ZG8aW;w9O`E%-_+gls7I(%CzaBP$Vi z{@lg7$^Vp`maY(U%U=1WB!~PX|{0PbaMDhld)FZmM51gwh?p#moE3mIbzG6#T;f?~0WE~t35u$Nm%i4Zu8 z13mj5kel^5+z|z~xcLjD2nNK8C@d5D21kgJ8>N*X)3oRTm^W`^{U>mcCY$fuVQ4i5 z0E}fp2Oa4P27Pv7@<0^Pf~8v@CbHoE)oo<6g|NS(5`7gzkt2dl1u^zG4}N6ZJr6h{ zj{Zat>culH20=A0SiDJM2zgVMyz@hl(B~1%-bmGx2Dx+n*WknIkYG!Ys%Ebux45#{ zpQ9h@b_CPAUC2pg;PrS)L1xA>3s;zqgiX!(X^HEZTdsoj+glJN=3YAch+1Xtmt5PY*1o*q!iE`bvc47WX7?v; zJI2&0o9A5d+2V<#Q--8%)tGw0RWVgFQ}FiVTySX46&ln3*x-HsIGDZ6dsZeqD^pNx z_*Yt=DqjIKZJ@mOT&L7t^XZ+izujE&;sdxtqilE5zK1jJ0lcuVe7>MIQBa#IpfgOF zZ@z3@qHJBNxMse1Rib#+HJ!?*u5?s-RT!chymL$HzxXs&Yetn05lZ+K=6T>0)~!5*&bLC*RhZViD5 zHzU{!zUtoALj7fnqpe=?mdVprqj;-E1$-aEf1=X>qd!#9o1w9R8X5^370tBn<7p@;>2*Lod>8w zWfWinQ3(@~G^}Pbg`I*jn98MH^zcT~E|Rz$L;#rDFDk`pt&4)(R3x|jrOTaoVYgpg z11e5wGRIwYxdV5u+A-gz;_OaKFj;^$65L(Xv+UVFi#o*kuwxJQfHJzclI)0(HWNQB zyI|-P7EYRx(6JC4OtKLhhgXqh9j_C|H-?9Cy{`{s=m5k13&hw6!jmxUfP#(edLv_y zFksM`Q3AwqX-iy8M@DuWx?_dXnBEVEN<@y5V}gPqI;NvD5_HoAJ?M%6XA+FrvO_Rp zJ8(S8kpJZ20Gu!*9Hb(ghk}?lXr~4Pfnna*EgW${2J{OCVfZD(B^e(VkE6VnfkiA? z`&~uw zZawS}OE^}HX;XE}#!M++j`4n0g>tylQOEg3CPt zL)Ef`XW7)+=|f4+rlf20ysJIoYEQa$fS$qP;yfD?mJO+jn)!;=iHgl6P*A~D-#2q)<`7r8Jz21Wv+fYbtMMQS;Hs^f6c^;V$0~IwhbY)kEzdjb z!0JJa(?wl?qaBnH5PsNiLN*uFaLxtbHF%CzuK+N@9?UEZ3P$}A*ip|u262h0=LH?< zvC z$(*4!<)trqmrb^VPS9IDQ8LMJh4o`QP=9vt>_IZeH-co~Y8h+4Y;nz7aK9ud?bJ!j z8cw_BRx3cMFFgvSy8ZDRVlRT0x503o*FH3Rgb!MBvWA?sqSOmvV#uFXvh}><6QoTz zVBaTw5pj7`ny$sZ!tRL6!D3O)s8EW7voo@>6dco9?_oT4r} z>3L^W0)*Fxlg^cwoL$p{^Q+qvtJ`OvNUrXJ&T4QjI10v3KDUuGmS47)W_0E2lJ>?+ z_FdD(^D9~tD_Upkk}Gz>(GdpM_>uF`IYZTj!j!XUqV?>~v9<+^XQKMc&N)j>s-Wmx z@N95Wl`N>aRIqib`eok*-*jhk+17Ez4;%#xMWqvw7uJsNg>!>QU+x6V3VPk(#T5xPn;;SzQo6vcx-y0c4#={4PuJeheJ1031tL_K|osQYcw~kQ6_W z{H{pcRZI)p#>0ccs)Qsa>o~%ZL5C#9SGehn;?Z`(5oyqR4K!NjUoZ*-6q;=d>lTuL zOwM=Aj?DKL^g`G0U}`e8lYFudCNsTM1hxp`!HSV|Cz1vWCiq(O8ry5PwsyB|-@E@1 zZ`|M3ePDm5fXGRAovnM?x_E8-fzGYn9eX=@13BYQI7@~7DQ1rN9{Uy&6cAV(L3tyd z85iu!M8ht-)HeSwFaeTg2#kCc$TQS6xk9By!}o$yJ5r7nr*;d(;yJbBvda21d4WoQ zT|udBY2?zYDYrLO29tX=2o5P{LCWDu(UmE8BbxR@P*F*Wu1b|vq$+AtC1vT#Z8DXf zSllB6s~Nj1ts$(I@|L7^gw<0;blN~zBUR{4n+R)0^Lz_ot(4i8wh`7&c}mj`!a8xg zfQzsN$QBaTjjV^TUaG*8E+T9(jnsE$Bgm{I+h87<+6;)rn&)e#u}g5szk8~Ya3k(nu!R7c`7 zRB97L0b)r7A6adJ>Qd2ZfguaC9YAxDq8w3J z5_IGGxFJVvV$W5FWS4DZJBwq~2^fpG?4nSm64zuFVQ>lpA~&E^DFF2dDpe%(CLbW4 zJ!K(Yz$iCBfkBm^iksk0jdy^(IO0xJ^p3jY_I%0>P+>^tCy#n^lpCIW3XJUe0Z?8S zh|u|CNv7_Zw~|iv81>#J1t^+D@qM%?s|JaC<3(`~s8~!Q7ByNNH-n1BOH?c<;4E=* zJEXC|`3bg*W=X|T3@R2gs93z9Vkr?5z**GrS)cJGbS<%|BQ-7S~h_?#X0JV;)<3yI91V= zi@=c+2&mCTClG*&MlZ(CcMu>^(G=$7sRGrIRZNk+E_zNOQPC7WS(tOufuoZ}{cWtQ zonUCWR_0_#6=pk7x8QgsXalYzN96Ez&|>Tjyd|P88Fvw^V*eS*zaaSl$^S)i6G%Jz zuRwjy95u)cc<{Qm$NK`r(nBzo>cIpky|Die$-g1F7YUK3&m-qTB1pB!kphrs7vrrL z$wNSVr2_Ouiu!9&f4qW^dK?HZ3*_iRR-yMUB-x_Lu)YA1r6Q#efa1sPU5*ZfoF}+w zG7*&^a_DlAcOXLm^XhJ|1X1XA$rh^D%~U6=H-iRaY}bSbM95#cW&{Oy&&2vE9p_!i zS??KBfpzh`qdDPdp4QB`l8#Md+AIJ0l_ihJD60qbBv|KQOI@~c=CPSRu4GHnzV&Sz zXWU6n&)OyX#SQwSYgL+{+dOY>vr@0S+m=&rS#@oVinlzLwmQXIbt>eSJKD>^^32m- ztoTl`3i#hBTcFAox&O+_7FLNPkRFKzham&hOwJG_j5#={#;R`-VDl*xx?uca9hefmFa)G>7cd0Hth}}TZ-Fs?2MT!I0U4g6u2@RvEh`e1 z71Iq#%O*~{NdOSpTFBd1#gf?kdCC1|WU|F$9OronW0!_I$#=OCdEo3ypFo)J(z*uw6yejG$LC zG`dFI49ul9=jA~-iw~?iV689sJtu<}!#LS{!x1=z@ej~M+k`nl451Cd=o*kMz}CRW zNm@16$Az{c3^BHb@IrD&@G-b^142fA6$wiL_a%_J6n=M!mhvmd_}S4t^V|xOOJ(M+ z3hr{tS-t2PHB2o$Uzv$p2T$;UnK+UBguyV{v!q~ zfdO&NFWAG;$o&M#6(p#H67WGXt?+nDbWj8-B&wjhK?)zhJrQ}3!gDagM*bLHmmq~j zm7r{vPwwSBt1^(`V~J{(^F{>7P;2*?Q&!K!j;y(LS^<8St>u$DvR2n=75L%QQGBU% z4<6|IP#?FK;YtE&4F-T2ch%&PnR{jJWRd2o@zcG`deM#@$; z>CReir%mWiGb}kq-Dw8~I6(E!lggJ?QJDY^9T0$?!W}gr3Ee4K_1pEx^+y=vzwJ=*ilw zDR{aJeQDvfC7bYcIr>&$+&alPJY5N%*9>k%Io*p;PVB39Wg+f_t|K~xiiqs|D9{gu zI*oBt4%F$q1JvmP7;`by35I2H@lbdQK~n%bwE#}SJ|uF=2!f&Rj72yAeIE*dN~g0x zDgt@NEdYR8;7+>%UJ4Arz)G6{px*q}-Enu^D*|Z%JtE3OT)ZeR04hP582~hYEXmeA z1M*3S;R1Y_KLzZskxEgL1ArFCOX5WUfLbH~v@~u70JQkF0H`xw40h)BxC73lK?xY# zXej`opz@6uF9JYqIRI#BUI4UofF>C%BJt9^0BC7m0MrfuXc?qH{Ar03>jpyUB|&ie zApmF@^sv@xl)SChrOW_2dAc8i2At3;^{I0MzrOCkFs^-5vlHW;Sv} z1RR^873MBt+OQqJ>g=p?87fGaP{`y#zWLo4g-jG#ytEztZeZkJ9xwfKOuim7szmNr zh?LR-Qc4QB1Suu&OWx?VFP)tt$xCNvM=~*zIgw07NN8Uk|QioEPn+;q{@D&nSpiu3Bf!lMBwau-+^wDYZ_rdUJrCY3n%eNu(bQ(>PJpHsQEzD6ZR@GGigftP1XY%{ zdlYZoqe6bYqrE}#9kZvsR`H!$74W}-Q=!flx!=I4cMYd58jAA4sYVd@egmhX23{1t z0I~ciaO&b3UThh>3@i870jIK?aSm$*k{?A~Tqsz!F1D%OjOE&bSs<|3DL|%mESZLh zx|7VsgsXxGOARc^)J#Ij{CpClkV%?wWYJ4PFH32EjNE@D75Ul})l;6TthrmOL*GFWOhqOGrV>^FQwb}8sf0CB&cd{bux6^bJZ&MY6_HLG zVeJU9Itc4Th}A{d0)$u#3F}6P)k9bTOeJhF;;JQtEk(ABurxxf<%F$3h_#Zi0H&6t zs|j0!53VI_9YU<+b<)caVy!2>?0-y%6&o53zatt5gyl%f$Ly$Ypz(x=#_o(Up73){ z84JB7p5&X7V)~TqGuWxo07W>$8oNCBzKkA3?+^p-rtOxqxgpMLi{h~NWASQ*)2`vy zrTa(VBnkMXX%@cH%!x0*j~tP^w<2eN1iTTf$l>=1o5*igk>Azg%^9!E?@;juJbi*# z=msLJ5>oPNIExPOO`(8l%u9m=K}!}P060LfV>dZHkNqOv8ajsXR6nw%h*yzA0NFpp z$X`bCBt|jr$8DiQ!VmKbdqRn!L>b2T6p|SvWD&8r1$am2-feBYTX(m1b@lFP-M0^p zxai`&#B*Cm*S_7YhkDWASPEOB4B@pKgkR#`$btr(f`4G;02DR^%4FB%YMJt%t(2_t zCzR=jl*Nljf;CX}wBj{-LrURp33f7f>e8 zI*xMPP|IXnWaArdPzo9ayJlj=0@-;l_x0g&(i@9rFFnhamj4(g9oSfz)vnd-Ii9t?Jpp@5Ml-s z{8M{VI@8lzFvNT_QVI3+P3@K%>J5c@%e~aMYjj&yD!#o!hV)+DR+-{WN(S`*1Em;x A#Q*>R literal 0 HcmV?d00001 diff --git a/__pycache__/sora_node.cpython-313.pyc b/__pycache__/sora_node.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a7de8c2784d35564e31b0ce9ab3afa101843e7f0 GIT binary patch literal 13335 zcmbt5ZEzFmc`L2HKW%+jmOt0FY$LD*96rDtU&h#g0h?&O9272!EUj&E^2)a>!{Htc zolaA8Ns38&#c4W?(`g%Dr%l{nA?KVl8^R!5lr$IE3r%^PLr%5!Er-eKX=dEKl(MH-B&)dfwqGQY{I>%h13-U~& zyOHXoSaTD_TCUfD0`E$TQhlp3DG;WnSlRLxz}Hn3*L7IB)23ej(du!-}6_C!#|!s2Mvya`iI|FWgLt z8DWkWvM`>Qio^8#7+B?JCN7PP51p&jjMzRzGKY1FJuV2DESZHg4)4}vJR47>2u3h+;t&l|kPF%uW^*L(OFeG}k{4F%0As3SF>2ZKtY0^!x`s+Xy*r+OIAea&I zOawCnW+9jrC}#`X6(>?|EHf45rG<3%!)9oy&_F1~bR#nrOC=Q(u4F;_*oB|*8kF=g z@Ks+^oFXsHX9S6l-H40VwKkvcQSKJNN3_nJ_$rh^JMKnSkVCkh)x791a2G; z2wB5LK&W~wLTafb@FM2d08UXte)uiwvBUiv=Zge*JMxB(qQCQphEBB+*58?KH=txI z-H?~A8(Eq)4A5=V_CMCa8c}B4(1|A2$(o4#FtaX{M<7WoAT2Bev$Ay{Uu@;NHr6iM zXRKk5VnZ4ar&F-4KJ0?Q7x!IE38^{Q_-7|4FEI&HLZnG$^F~M5N!X6b`3wjcDe|#- z?@bT`8EFQZLmEGsC<|ZmiYx&?-P3hI-k~_%E|N`ANg(32@+Jb7(7jy7dEJ$|bS0nD zM{9d4w`KKOZH`Lg2qbZI6xnrKs@By;j)OSn=m`U>=V%voKo5OX#<+4aZPGfJp~Cd| zqVHl#l2C*qr8LQ<==O%qitTbdy}*-=t(ZY%#6YGgCfIgjYF@ES#eo4A#I#}qHidnR zB3Q9#gj3Af)Eu8#$byJ4NPK!)?16DboTIoK!5#o)!!ejdWHj9R@O84VjlI?H7SS@X z1L*>QyVTEJR9#@x(;~MX$a@B4$3QXA_60|=k@=eYEABNq-`KZg_=2+->MMi}L9Ah4tf#;nD;<`K}&TCh~7A@IX3Td{{5`!9y|6$8Y0Km9Qs`2(|=>$}H^d z%#`A5L;Gco#wNNS+XEs&4Mbqhp<5>fa-d< z)$BL!eErU9X8lv^H`iz7(22bJq-;5h+Xh;0#feq}!5aB?(3r>^7FtNXk&xP(qUwMG3tv zb=H>GZ7YP=C~l!`iK_?_*1#GE^)LnsXOcKQYtqJs*_t?06qVq%^r}%4MPLnXn=hvi zQ5{qtr3s!Se1T~gqn%>lZ+!L`-A+yGUR(p)t98QWaS^!ytQgZApPkJl-_;QbF$p;q z{>_w>jb&a}^tYzM4#fmQC!Ly7%;hbmn4{|6Qp`|-W({eo7+@bI6@5lhEOYVK`6L(& ziUBOcS;Y)ES`vx@Yb(ZEVk*mr&FZG4QKs2Jq|NZz7*-cg!pszlrgRkp+F6PLb*qFt z0pbgjD#Bcv_KkrZ#%Gma4a09OIs#3lKLBu-+VnI&j68@GJiBt9U2EQTPWJ4QJ+Ca8 zH=W-5pT76$g0m~<>{>nZ?dyMWT}J%W#%$5s{P5U=V_!b8bowKjat-T#YO&q7zit1W zQxDHPIP-Ag!NltMyl>y5fq$}`+;rA2?^(%y-x*pn6diT<$L@_SXTR@g!G_(B-A%Hk z=_5P#@dM|nk3TX&CRA1PKPLMAyQcb6gY<8 zjBx-YGDa(@dAcZ0k6K9pr(|atilwZcr6YRWBEXpRR5$3-%1BY%^4n4;XgrNkchr&w ztyC~wBW@LT5Up&uYIjkzi?*dJqgOr!+s7c7+o(@yjrP;FbY(t@=H#}-nuw6FaF%Fg zOIE9BLSm;a$yV25-M%Gjs1&O9reU4btJY&x3k9?eY-*cEnP0k@`?bwnvwjrx+fuY? zTL!$EsrX5X5^S99|FKg0wN|ZX)(kzhX>ZjhXekS42kxoRhqLi|;2_(OZCdvU^Jc33 zXc){JMiCq}BU?E~6cyFBgdD3;k#mqS9Yc<3-Lxj&?Bk2(Q+!hBw7hzb%JPT5d7bWo(coWe^ zS*$}DLrgc~M=`q=>F^O|kr)r)Wf8lBnT~_JNo_4T(_1-bR*VoT(S zk@nh_%q0ue(Y<6S+C4dY+om`0@W_{rtjy%SJq2%n&fC8R=)tAa+XVVXfC%&}&|Gh} z{)%q1KJc*ZL0h4|H&@@gX8xO&@3hE>kAASL7-)ZZ=fRyXf3^_l&jql*z`~w+im=oV)uerMD(^#bDRh z+P~7C5AH4m_veE9*L(B9BTHkOo;JB-|N8E4@BNFt^8QmBT^lzxuFLJwyoZ&qCS*tA z7n@pNp`1JPl+xj(vC+Zx+l51Ca)-{y2hYmw=klI0`O;O{arGA^8tZ&4A*Xz|uXlJ4 z^`4g*Zld2iU;+GlO}61(=I@1Q#CH+6r)`v`|JM4-h=G3J&;j`S-NBLL^!vw+fQJn` zxeH;Nnu}(N%D@C8?gYV*LlxsRuvr%T6h6Bk3W{MoBk<54VKS6XViEyjkc%QrByFp? zCY2XS?)k0dm>1TKhb=1ik}8CS35G)~HdegxtzP?UHRD2z4g1~di;HN}DgM(VqeI+< z$ruR_of=|CVz7_IeyopnzjzqI1OjC29sP9%3lMa{Pl^Hf9QDtJBPAOZj1(I?iow>B z-C=BhT<NXoneg0oq=HU)jh0vDsU6Hy7L;Id@0i-6dPPHeGf1Q*WnM zTzOY-!8Q74FMVt9PY30fNAi0{@1FUw-LvUxl!J45mmpgN5Rmte-8=UA6SAR6b=sik zN-En}x~KmISlf>18p17# z8};`jgrp}kmoe1zVH~Kk_{TPOVQMag0a6BH0ZcYSc%`E2<3f_jlq0+8+su3>otBsd zA)87=0}`@gAnc7k(Cr9>zw>t~k0hOgx>@Lg^R{)7%w^ zELV1OaCgI~hGUenymF?gW6rLZmAODo`3@kW;bD@B#$w zv-}o8f#xw06#obaoMwPOjuAoD7|{>t_u-p~HLGu=M8j1EfX^SH2he43vaWr)GG-o=VaBs0?M0UbAbVPa_VKY&`0d?#mAq;#T;JU#)$zYUoRJ(mI%C}sJ1 z^8T@5|G^`J3Vm*T@q@u~T=+7VD)w9a)NCg6I*C2HZ^c2#!D$dmGh{F)?gNM- z!tu_{cv`8Oi@zBob+oaa8e0?J9!Lz1{TmA@5vE|#wo_OqP9tG-3Y|zx5)!HG-rLWO zU*aZXlUFZ|uuAi`eH*DMOhX"ps4Xw~}Q%Hj0^`E(}NJuf%CQKI!h7i70~ua9g@ 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 literal 0 HcmV?d00001 diff --git a/quick_test.py b/quick_test.py new file mode 100644 index 0000000..bab6bc6 --- /dev/null +++ b/quick_test.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +""" +快速测试脚本 - 验证节点是否可以正常导入和使用 + +这个脚本可以快速检查: +1. 节点代码是否可以正常导入 +2. 节点类是否可以实例化 +3. 节点映射是否正确 +""" + +def quick_test(): + """快速测试节点基本功能""" + print("🔍 开始快速测试...\n") + + try: + # 1. 测试导入 + print("1. 测试模块导入...") + from sora_node import ( + SoraClient, + SoraTextToVideo, + SoraImageToVideo, + SoraVideoToVideo, + NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS + ) + print(" ✓ 模块导入成功\n") + + # 2. 测试节点类 + print("2. 测试节点类...") + # Text-to-Video + node_t2v = SoraTextToVideo() + input_types = node_t2v.INPUT_TYPES() + assert "required" in input_types + assert "prompt" in input_types["required"] + print(" ✓ SoraTextToVideo 节点正常") + + # Image-to-Video + node_i2v = SoraImageToVideo() + input_types_i2v = node_i2v.INPUT_TYPES() + assert "image" in input_types_i2v["required"] + assert "prompt" in input_types_i2v["required"] + print(" ✓ SoraImageToVideo 节点正常") + + # Video-to-Video + node_v2v = SoraVideoToVideo() + input_types_v2v = node_v2v.INPUT_TYPES() + assert "video" in input_types_v2v["required"] + assert "prompt" in input_types_v2v["required"] + print(" ✓ SoraVideoToVideo 节点正常") + print(" ✓ 所有节点类实例化成功\n") + + # 3. 测试节点映射 + print("3. 测试节点映射...") + assert "SoraTextToVideo" in NODE_CLASS_MAPPINGS + assert "SoraImageToVideo" in NODE_CLASS_MAPPINGS + assert "SoraVideoToVideo" in NODE_CLASS_MAPPINGS + assert NODE_CLASS_MAPPINGS["SoraTextToVideo"] == SoraTextToVideo + assert NODE_CLASS_MAPPINGS["SoraImageToVideo"] == SoraImageToVideo + assert NODE_CLASS_MAPPINGS["SoraVideoToVideo"] == SoraVideoToVideo + assert "SoraTextToVideo" in NODE_DISPLAY_NAME_MAPPINGS + assert "SoraImageToVideo" in NODE_DISPLAY_NAME_MAPPINGS + assert "SoraVideoToVideo" in NODE_DISPLAY_NAME_MAPPINGS + print(" ✓ 所有节点映射正确\n") + + # 4. 测试客户端初始化(不需要真实 API) + print("4. 测试客户端类...") + try: + client = SoraClient(api_key="test-key-123") + assert client.api_key == "test-key-123" + print(" ✓ 客户端类正常\n") + except Exception as e: + print(f" ✗ 客户端测试失败: {e}\n") + + print("=" * 50) + print("✅ 所有快速测试通过!") + print("=" * 50) + print("\n节点已准备好,可以:") + print(" 1. 复制到 ComfyUI/custom_nodes/ 目录") + print(" 2. 重启 ComfyUI") + print(" 3. 在节点菜单中找到以下节点:") + print(" - Sora Text-To-Video") + print(" - Sora Image-To-Video") + print(" - Sora Video-To-Video") + return True + + except ImportError as e: + print(f" ✗ 导入失败: {e}") + print("\n请确保 sora_node.py 在同一目录下") + return False + except Exception as e: + print(f" ✗ 测试失败: {e}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + quick_test() + diff --git a/sora_node.py b/sora_node.py new file mode 100644 index 0000000..48d9523 --- /dev/null +++ b/sora_node.py @@ -0,0 +1,847 @@ +""" +ComfyUI custom nodes for interacting with a Sora-compatible API. + +This module avoids external dependencies so it can run inside a vanilla +ComfyUI installation. Configure the Sora API key through the node input, +the `SORA_API_KEY` environment variable, or `OPENAI_API_KEY`. +""" + +from __future__ import annotations + +import base64 +import io +import json +import os +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import Any, Dict, Optional, Union, Tuple + +# Try to import PIL and numpy for image handling (optional dependencies) +try: + from PIL import Image + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + Image = None + +try: + import numpy as np + NUMPY_AVAILABLE = True +except ImportError: + NUMPY_AVAILABLE = False + np = None + + +DEFAULT_SORA_BASE_URL = "https://api.openai.com/v1/sora" + + +class SoraAPIError(RuntimeError): + """Raised when the upstream Sora service reports an error.""" + + +@dataclass +class SoraJobResult: + job_id: str + status: str + response_json: Dict[str, Any] + downloaded_file: Optional[str] + + +class SoraClient: + """Minimal HTTP client for the Sora REST endpoints.""" + + def __init__( + self, + api_key: str, + base_url: str = DEFAULT_SORA_BASE_URL, + endpoint: str = "/videos", + timeout: float = 60.0, + ) -> None: + if not api_key: + raise ValueError("Missing Sora API key") + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}" + self.timeout = timeout + + def create_job(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("POST", self.endpoint, payload) + + def create_job_with_files( + self, + payload: Dict[str, Any], + files: Optional[Dict[str, Tuple[bytes, str]]] = None, + ) -> Dict[str, Any]: + """ + 创建任务并上传文件(multipart/form-data)。 + + Args: + payload: JSON 数据 + files: 文件字典,格式为 {field_name: (file_bytes, mime_type)} + """ + return self._request_multipart("POST", self.endpoint, payload, files) + + def fetch_job(self, job_id: str) -> Dict[str, Any]: + return self._request("GET", f"{self.endpoint}/{job_id}") + + def download_asset(self, url: str, target_path: str) -> str: + request = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(request, timeout=self.timeout) as response: + data = response.read() + os.makedirs(os.path.dirname(target_path), exist_ok=True) + with open(target_path, "wb") as file: + file.write(data) + return target_path + + def _request(self, method: str, path: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + url = f"{self.base_url}{path}" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + data: Optional[bytes] = None + if payload is not None: + data = json.dumps(payload).encode("utf-8") + request = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + body = response.read().decode("utf-8") + except urllib.error.HTTPError as http_error: + error_body = http_error.read().decode("utf-8") + raise SoraAPIError(f"Sora API error ({http_error.code}): {error_body}") from http_error + except urllib.error.URLError as url_error: + raise SoraAPIError(f"Sora request failed: {url_error.reason}") from url_error + return json.loads(body or "{}") + + def _request_multipart( + self, + method: str, + path: str, + payload: Dict[str, Any], + files: Optional[Dict[str, Tuple[bytes, str]]] = None, + ) -> Dict[str, Any]: + """ + 发送 multipart/form-data 请求(用于文件上传)。 + """ + import uuid + + url = f"{self.base_url}{path}" + boundary = f"----WebKitFormBoundary{uuid.uuid4().hex}" + + # 构建 multipart 数据 + parts = [] + + # 添加 JSON 字段 + for key, value in payload.items(): + if value is not None: + parts.append(f'--{boundary}\r\n'.encode()) + parts.append(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode()) + if isinstance(value, (dict, list)): + parts.append(json.dumps(value).encode("utf-8")) + else: + parts.append(str(value).encode("utf-8")) + parts.append(b'\r\n') + + # 添加文件字段 + if files: + for field_name, (file_bytes, mime_type) in files.items(): + parts.append(f'--{boundary}\r\n'.encode()) + parts.append( + f'Content-Disposition: form-data; name="{field_name}"; filename="file"\r\n'.encode() + ) + parts.append(f'Content-Type: {mime_type}\r\n\r\n'.encode()) + parts.append(file_bytes) + parts.append(b'\r\n') + + parts.append(f'--{boundary}--\r\n'.encode()) + data = b''.join(parts) + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + } + + request = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + body = response.read().decode("utf-8") + except urllib.error.HTTPError as http_error: + error_body = http_error.read().decode("utf-8") + raise SoraAPIError(f"Sora API error ({http_error.code}): {error_body}") from http_error + except urllib.error.URLError as url_error: + raise SoraAPIError(f"Sora request failed: {url_error.reason}") from url_error + return json.loads(body or "{}") + + +def _resolve_api_key(explicit_key: Optional[str]) -> str: + if explicit_key: + return explicit_key.strip() + env_key = os.getenv("SORA_API_KEY") or os.getenv("OPENAI_API_KEY") + if not env_key: + raise ValueError("Provide a Sora API key via the node input or SORA_API_KEY/OPENAI_API_KEY env vars.") + return env_key.strip() + + +def _process_image_input(image_input: Any) -> Tuple[bytes, str]: + """ + 处理 ComfyUI 的图像输入,转换为字节数据。 + + 支持: + - PIL.Image 对象 + - numpy 数组 + - 文件路径字符串 + - base64 编码的字符串 + + 返回: (image_bytes, mime_type) + """ + # 如果是字符串,可能是文件路径或 base64 + if isinstance(image_input, str): + # 检查是否是 base64 + if image_input.startswith("data:image") or (len(image_input) > 100 and not os.path.exists(image_input)): + # 可能是 base64,尝试解码 + if "," in image_input: + # data:image/png;base64,xxx 格式 + header, data = image_input.split(",", 1) + image_bytes = base64.b64decode(data) + mime_type = "image/png" # 默认 + if "image/" in header: + mime_type = header.split("image/")[1].split(";")[0] + return image_bytes, mime_type + else: + # 纯 base64 + image_bytes = base64.b64decode(image_input) + return image_bytes, "image/png" + elif os.path.exists(image_input): + # 文件路径 + with open(image_input, "rb") as f: + image_bytes = f.read() + ext = os.path.splitext(image_input)[1].lower() + mime_types = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"} + mime_type = mime_types.get(ext, "image/png") + return image_bytes, mime_type + + # 如果是 PIL Image + if PIL_AVAILABLE and isinstance(image_input, Image.Image): + buffer = io.BytesIO() + # 转换为 RGB 如果必要 + if image_input.mode != "RGB": + image_input = image_input.convert("RGB") + image_input.save(buffer, format="PNG") + return buffer.getvalue(), "image/png" + + # 如果是 numpy 数组 + if NUMPY_AVAILABLE and isinstance(image_input, np.ndarray): + # 处理不同的数组形状 + img_array = image_input + if len(img_array.shape) == 4: # [batch, H, W, C] + img_array = img_array[0] + elif len(img_array.shape) == 3 and img_array.shape[0] < img_array.shape[2]: # [C, H, W] + img_array = np.transpose(img_array, (1, 2, 0)) + + # 确保值在 0-255 范围 + if img_array.max() <= 1.0: + img_array = (img_array * 255).astype(np.uint8) + else: + img_array = img_array.astype(np.uint8) + + # 转换为 PIL Image 然后编码 + if PIL_AVAILABLE: + pil_image = Image.fromarray(img_array) + buffer = io.BytesIO() + pil_image.save(buffer, format="PNG") + return buffer.getvalue(), "image/png" + else: + raise ValueError("PIL is required to process numpy arrays. Install Pillow.") + + # 如果已经是字节数据 + if isinstance(image_input, bytes): + return image_input, "image/png" + + raise ValueError(f"Unsupported image input type: {type(image_input)}") + + +def _process_video_input(video_input: Any) -> Tuple[bytes, str]: + """ + 处理视频输入,转换为字节数据。 + + 支持: + - 文件路径字符串 + - base64 编码的字符串 + - 字节数据 + + 返回: (video_bytes, mime_type) + """ + if isinstance(video_input, str): + # 检查是否是 base64 + if video_input.startswith("data:video") or (len(video_input) > 100 and not os.path.exists(video_input)): + if "," in video_input: + header, data = video_input.split(",", 1) + video_bytes = base64.b64decode(data) + mime_type = "video/mp4" + if "video/" in header: + mime_type = header.split("video/")[1].split(";")[0] + return video_bytes, mime_type + else: + video_bytes = base64.b64decode(video_input) + return video_bytes, "video/mp4" + elif os.path.exists(video_input): + with open(video_input, "rb") as f: + video_bytes = f.read() + ext = os.path.splitext(video_input)[1].lower() + mime_types = {".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".webm": "video/webm"} + mime_type = mime_types.get(ext, "video/mp4") + return video_bytes, mime_type + + if isinstance(video_input, bytes): + return video_input, "video/mp4" + + raise ValueError(f"Unsupported video input type: {type(video_input)}") + + +class SoraTextToVideo: + """ + Submits a job to the Sora API and optionally polls until a video is ready. + The node returns diagnostic metadata along with the downloaded file path (if requested). + """ + + CATEGORY = "Sora" + RETURN_TYPES = ("STRING", "STRING", "STRING") + RETURN_NAMES = ("job_id", "status", "result") + FUNCTION = "generate" + + @classmethod + def INPUT_TYPES(cls) -> Dict[str, Dict[str, Any]]: + return { + "required": { + "prompt": ("STRING", {"multiline": True, "default": "A cinematic drone shot of waves crashing."}), + }, + "optional": { + "api_key": ("STRING", {"multiline": False, "default": ""}), + "negative_prompt": ("STRING", {"multiline": True, "default": ""}), + "duration_seconds": ("FLOAT", {"default": 5.0, "min": 1.0, "step": 0.5}), + "aspect_ratio": ("STRING", {"default": "16:9"}), + "seed": ("STRING", {"default": ""}), + "fps": ("INT", {"default": 24, "min": 1}), + "guidance_scale": ("FLOAT", {"default": 7.5, "min": 0.0, "step": 0.1}), + "base_url": ("STRING", {"default": DEFAULT_SORA_BASE_URL}), + "endpoint": ("STRING", {"default": "/videos"}), + "metadata": ("STRING", {"multiline": True, "default": ""}), + "webhook_url": ("STRING", {"default": ""}), + "wait_for_result": ("BOOLEAN", {"default": True}), + "poll_interval": ("FLOAT", {"default": 3.0, "min": 1.0}), + "max_wait_seconds": ("FLOAT", {"default": 120.0, "min": 5.0}), + "download_path": ("STRING", {"default": ""}), + }, + } + + def generate( + self, + prompt: str, + api_key: str = "", + negative_prompt: str = "", + duration_seconds: float = 5.0, + aspect_ratio: str = "16:9", + seed: str = "", + fps: int = 24, + guidance_scale: float = 7.5, + base_url: str = DEFAULT_SORA_BASE_URL, + endpoint: str = "/videos", + metadata: str = "", + webhook_url: str = "", + wait_for_result: bool = True, + poll_interval: float = 3.0, + max_wait_seconds: float = 120.0, + download_path: str = "", + ) -> tuple[str, str, str]: + client = SoraClient( + api_key=_resolve_api_key(api_key), + base_url=base_url, + endpoint=endpoint, + ) + + payload: Dict[str, Any] = { + "prompt": prompt, + "negative_prompt": negative_prompt or None, + "duration_seconds": duration_seconds, + "aspect_ratio": aspect_ratio, + "seed": seed or None, + "fps": fps, + "guidance_scale": guidance_scale, + } + if metadata: + try: + payload["metadata"] = json.loads(metadata) + except json.JSONDecodeError as error: + raise ValueError(f"Metadata must be valid JSON: {error.msg}") from error + if webhook_url: + payload["webhook_url"] = webhook_url + + response = client.create_job(payload) + job_id = str(response.get("id") or response.get("job_id") or "") + status = response.get("status", "submitted") + job_result = SoraJobResult(job_id=job_id, status=status, response_json=response, downloaded_file=None) + + if wait_for_result and job_id: + job_result = self._poll_until_complete( + client=client, + job_id=job_id, + poll_interval=poll_interval, + max_wait_seconds=max_wait_seconds, + download_path=download_path, + ) + elif wait_for_result and not job_id: + raise SoraAPIError("Sora API response did not include a job id.") + + result_payload = { + "job_id": job_result.job_id, + "status": job_result.status, + "downloaded_file": job_result.downloaded_file, + "raw_response": job_result.response_json, + } + return ( + job_result.job_id or "", + job_result.status, + json.dumps(result_payload, ensure_ascii=False), + ) + + def _poll_until_complete( + self, + client: SoraClient, + job_id: str, + poll_interval: float, + max_wait_seconds: float, + download_path: str, + ) -> SoraJobResult: + deadline = time.time() + max_wait_seconds + last_response: Dict[str, Any] = {} + downloaded_file: Optional[str] = None + + while time.time() < deadline: + last_response = client.fetch_job(job_id) + status = str(last_response.get("status", "unknown")).lower() + if status in {"succeeded", "completed", "ready"}: + downloaded_file = self._maybe_download(last_response, client, download_path, job_id) + return SoraJobResult( + job_id=job_id, + status=status, + response_json=last_response, + downloaded_file=downloaded_file, + ) + if status in {"failed", "error"}: + message = last_response.get("error") or last_response + raise SoraAPIError(f"Sora generation failed: {message}") + time.sleep(poll_interval) + + raise TimeoutError(f"Sora job {job_id} did not finish within {max_wait_seconds} seconds.") + + def _maybe_download( + self, + job_response: Dict[str, Any], + client: SoraClient, + download_path: str, + job_id: str, + ) -> Optional[str]: + if not download_path: + return None + + video_url = self._extract_video_url(job_response) + if not video_url: + return None + + target = download_path + if os.path.isdir(download_path): + safe_job_id = job_id or "sora" + filename = f"{safe_job_id}.mp4" + target = os.path.join(download_path, filename) + + return client.download_asset(video_url, target) + + @staticmethod + def _extract_video_url(job_response: Dict[str, Any]) -> Optional[str]: + video_url = job_response.get("result_url") or job_response.get("video_url") + if video_url: + return str(video_url) + + outputs = job_response.get("outputs") or job_response.get("videos") + if isinstance(outputs, list) and outputs: + first = outputs[0] + if isinstance(first, dict): + url = first.get("url") or first.get("download_url") + if url: + return str(url) + return None + + +class SoraImageToVideo: + """ + Image-to-Video node: Generates video from an image input. + Supports ComfyUI image inputs (PIL Image, numpy arrays, file paths). + """ + + CATEGORY = "Sora" + RETURN_TYPES = ("STRING", "STRING", "STRING") + RETURN_NAMES = ("job_id", "status", "result") + FUNCTION = "generate" + + @classmethod + def INPUT_TYPES(cls) -> Dict[str, Dict[str, Any]]: + return { + "required": { + "image": ("IMAGE",), # ComfyUI image input + "prompt": ("STRING", {"multiline": True, "default": "A cinematic video based on this image."}), + }, + "optional": { + "api_key": ("STRING", {"multiline": False, "default": ""}), + "negative_prompt": ("STRING", {"multiline": True, "default": ""}), + "duration_seconds": ("FLOAT", {"default": 5.0, "min": 1.0, "step": 0.5}), + "aspect_ratio": ("STRING", {"default": "16:9"}), + "seed": ("STRING", {"default": ""}), + "fps": ("INT", {"default": 24, "min": 1}), + "guidance_scale": ("FLOAT", {"default": 7.5, "min": 0.0, "step": 0.1}), + "base_url": ("STRING", {"default": DEFAULT_SORA_BASE_URL}), + "endpoint": ("STRING", {"default": "/videos"}), + "metadata": ("STRING", {"multiline": True, "default": ""}), + "webhook_url": ("STRING", {"default": ""}), + "wait_for_result": ("BOOLEAN", {"default": True}), + "poll_interval": ("FLOAT", {"default": 3.0, "min": 1.0}), + "max_wait_seconds": ("FLOAT", {"default": 120.0, "min": 5.0}), + "download_path": ("STRING", {"default": ""}), + }, + } + + def generate( + self, + image: Any, + prompt: str, + api_key: str = "", + negative_prompt: str = "", + duration_seconds: float = 5.0, + aspect_ratio: str = "16:9", + seed: str = "", + fps: int = 24, + guidance_scale: float = 7.5, + base_url: str = DEFAULT_SORA_BASE_URL, + endpoint: str = "/videos", + metadata: str = "", + webhook_url: str = "", + wait_for_result: bool = True, + poll_interval: float = 3.0, + max_wait_seconds: float = 120.0, + download_path: str = "", + ) -> tuple[str, str, str]: + client = SoraClient( + api_key=_resolve_api_key(api_key), + base_url=base_url, + endpoint=endpoint, + ) + + # 处理图像输入 + try: + image_bytes, mime_type = _process_image_input(image) + except Exception as e: + raise ValueError(f"Failed to process image input: {e}") from e + + # 构建 payload + payload: Dict[str, Any] = { + "prompt": prompt, + "negative_prompt": negative_prompt or None, + "duration_seconds": duration_seconds, + "aspect_ratio": aspect_ratio, + "seed": seed or None, + "fps": fps, + "guidance_scale": guidance_scale, + } + if metadata: + try: + payload["metadata"] = json.loads(metadata) + except json.JSONDecodeError as error: + raise ValueError(f"Metadata must be valid JSON: {error.msg}") from error + if webhook_url: + payload["webhook_url"] = webhook_url + + # 上传图像和创建任务 + files = {"image": (image_bytes, mime_type)} + response = client.create_job_with_files(payload, files) + + job_id = str(response.get("id") or response.get("job_id") or "") + status = response.get("status", "submitted") + job_result = SoraJobResult(job_id=job_id, status=status, response_json=response, downloaded_file=None) + + if wait_for_result and job_id: + job_result = self._poll_until_complete( + client=client, + job_id=job_id, + poll_interval=poll_interval, + max_wait_seconds=max_wait_seconds, + download_path=download_path, + ) + elif wait_for_result and not job_id: + raise SoraAPIError("Sora API response did not include a job id.") + + result_payload = { + "job_id": job_result.job_id, + "status": job_result.status, + "downloaded_file": job_result.downloaded_file, + "raw_response": job_result.response_json, + } + return ( + job_result.job_id or "", + job_result.status, + json.dumps(result_payload, ensure_ascii=False), + ) + + def _poll_until_complete( + self, + client: SoraClient, + job_id: str, + poll_interval: float, + max_wait_seconds: float, + download_path: str, + ) -> SoraJobResult: + deadline = time.time() + max_wait_seconds + last_response: Dict[str, Any] = {} + downloaded_file: Optional[str] = None + + while time.time() < deadline: + last_response = client.fetch_job(job_id) + status = str(last_response.get("status", "unknown")).lower() + if status in {"succeeded", "completed", "ready"}: + downloaded_file = self._maybe_download(last_response, client, download_path, job_id) + return SoraJobResult( + job_id=job_id, + status=status, + response_json=last_response, + downloaded_file=downloaded_file, + ) + if status in {"failed", "error"}: + message = last_response.get("error") or last_response + raise SoraAPIError(f"Sora generation failed: {message}") + time.sleep(poll_interval) + + raise TimeoutError(f"Sora job {job_id} did not finish within {max_wait_seconds} seconds.") + + def _maybe_download( + self, + job_response: Dict[str, Any], + client: SoraClient, + download_path: str, + job_id: str, + ) -> Optional[str]: + if not download_path: + return None + + video_url = self._extract_video_url(job_response) + if not video_url: + return None + + target = download_path + if os.path.isdir(download_path): + safe_job_id = job_id or "sora" + filename = f"{safe_job_id}.mp4" + target = os.path.join(download_path, filename) + + return client.download_asset(video_url, target) + + @staticmethod + def _extract_video_url(job_response: Dict[str, Any]) -> Optional[str]: + return SoraTextToVideo._extract_video_url(job_response) + + +class SoraVideoToVideo: + """ + Video-to-Video node: Edits or extends an existing video. + Supports video extension, inpainting, and style transfer. + """ + + CATEGORY = "Sora" + RETURN_TYPES = ("STRING", "STRING", "STRING") + RETURN_NAMES = ("job_id", "status", "result") + FUNCTION = "generate" + + @classmethod + def INPUT_TYPES(cls) -> Dict[str, Dict[str, Any]]: + return { + "required": { + "video": ("STRING", {"default": ""}), # Video file path or base64 + "prompt": ("STRING", {"multiline": True, "default": "Extend this video with smooth continuation."}), + }, + "optional": { + "api_key": ("STRING", {"multiline": False, "default": ""}), + "negative_prompt": ("STRING", {"multiline": True, "default": ""}), + "operation": (["extend", "edit", "inpaint", "style_transfer"], {"default": "extend"}), + "extension_seconds": ("FLOAT", {"default": 5.0, "min": 1.0, "step": 0.5}), + "duration_seconds": ("FLOAT", {"default": 5.0, "min": 1.0, "step": 0.5}), + "aspect_ratio": ("STRING", {"default": "16:9"}), + "seed": ("STRING", {"default": ""}), + "fps": ("INT", {"default": 24, "min": 1}), + "guidance_scale": ("FLOAT", {"default": 7.5, "min": 0.0, "step": 0.1}), + "base_url": ("STRING", {"default": DEFAULT_SORA_BASE_URL}), + "endpoint": ("STRING", {"default": "/videos"}), + "metadata": ("STRING", {"multiline": True, "default": ""}), + "webhook_url": ("STRING", {"default": ""}), + "wait_for_result": ("BOOLEAN", {"default": True}), + "poll_interval": ("FLOAT", {"default": 3.0, "min": 1.0}), + "max_wait_seconds": ("FLOAT", {"default": 120.0, "min": 5.0}), + "download_path": ("STRING", {"default": ""}), + }, + } + + def generate( + self, + video: str, + prompt: str, + api_key: str = "", + negative_prompt: str = "", + operation: str = "extend", + extension_seconds: float = 5.0, + duration_seconds: float = 5.0, + aspect_ratio: str = "16:9", + seed: str = "", + fps: int = 24, + guidance_scale: float = 7.5, + base_url: str = DEFAULT_SORA_BASE_URL, + endpoint: str = "/videos", + metadata: str = "", + webhook_url: str = "", + wait_for_result: bool = True, + poll_interval: float = 3.0, + max_wait_seconds: float = 120.0, + download_path: str = "", + ) -> tuple[str, str, str]: + client = SoraClient( + api_key=_resolve_api_key(api_key), + base_url=base_url, + endpoint=endpoint, + ) + + # 处理视频输入 + try: + video_bytes, mime_type = _process_video_input(video) + except Exception as e: + raise ValueError(f"Failed to process video input: {e}") from e + + # 构建 payload + payload: Dict[str, Any] = { + "prompt": prompt, + "negative_prompt": negative_prompt or None, + "operation": operation, + "duration_seconds": duration_seconds, + "aspect_ratio": aspect_ratio, + "seed": seed or None, + "fps": fps, + "guidance_scale": guidance_scale, + } + + # 根据操作类型添加特定参数 + if operation == "extend": + payload["extension_seconds"] = extension_seconds + + if metadata: + try: + payload["metadata"] = json.loads(metadata) + except json.JSONDecodeError as error: + raise ValueError(f"Metadata must be valid JSON: {error.msg}") from error + if webhook_url: + payload["webhook_url"] = webhook_url + + # 上传视频和创建任务 + files = {"video": (video_bytes, mime_type)} + response = client.create_job_with_files(payload, files) + + job_id = str(response.get("id") or response.get("job_id") or "") + status = response.get("status", "submitted") + job_result = SoraJobResult(job_id=job_id, status=status, response_json=response, downloaded_file=None) + + if wait_for_result and job_id: + job_result = self._poll_until_complete( + client=client, + job_id=job_id, + poll_interval=poll_interval, + max_wait_seconds=max_wait_seconds, + download_path=download_path, + ) + elif wait_for_result and not job_id: + raise SoraAPIError("Sora API response did not include a job id.") + + result_payload = { + "job_id": job_result.job_id, + "status": job_result.status, + "downloaded_file": job_result.downloaded_file, + "raw_response": job_result.response_json, + } + return ( + job_result.job_id or "", + job_result.status, + json.dumps(result_payload, ensure_ascii=False), + ) + + def _poll_until_complete( + self, + client: SoraClient, + job_id: str, + poll_interval: float, + max_wait_seconds: float, + download_path: str, + ) -> SoraJobResult: + deadline = time.time() + max_wait_seconds + last_response: Dict[str, Any] = {} + downloaded_file: Optional[str] = None + + while time.time() < deadline: + last_response = client.fetch_job(job_id) + status = str(last_response.get("status", "unknown")).lower() + if status in {"succeeded", "completed", "ready"}: + downloaded_file = self._maybe_download(last_response, client, download_path, job_id) + return SoraJobResult( + job_id=job_id, + status=status, + response_json=last_response, + downloaded_file=downloaded_file, + ) + if status in {"failed", "error"}: + message = last_response.get("error") or last_response + raise SoraAPIError(f"Sora generation failed: {message}") + time.sleep(poll_interval) + + raise TimeoutError(f"Sora job {job_id} did not finish within {max_wait_seconds} seconds.") + + def _maybe_download( + self, + job_response: Dict[str, Any], + client: SoraClient, + download_path: str, + job_id: str, + ) -> Optional[str]: + if not download_path: + return None + + video_url = self._extract_video_url(job_response) + if not video_url: + return None + + target = download_path + if os.path.isdir(download_path): + safe_job_id = job_id or "sora" + filename = f"{safe_job_id}.mp4" + target = os.path.join(download_path, filename) + + return client.download_asset(video_url, target) + + @staticmethod + def _extract_video_url(job_response: Dict[str, Any]) -> Optional[str]: + return SoraTextToVideo._extract_video_url(job_response) + + +NODE_CLASS_MAPPINGS = { + "SoraTextToVideo": SoraTextToVideo, + "SoraImageToVideo": SoraImageToVideo, + "SoraVideoToVideo": SoraVideoToVideo, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SoraTextToVideo": "Sora Text-To-Video", + "SoraImageToVideo": "Sora Image-To-Video", + "SoraVideoToVideo": "Sora Video-To-Video", +} diff --git a/test_sora_node.py b/test_sora_node.py new file mode 100644 index 0000000..e70ee63 --- /dev/null +++ b/test_sora_node.py @@ -0,0 +1,248 @@ +""" +本地测试脚本 - 用于测试 Sora 自定义节点 + +使用方法: +1. 直接运行测试(需要 mock API): + python test_sora_node.py + +2. 测试节点类的基本功能: + python test_sora_node.py --test-class + +3. 测试 API 客户端(需要真实的 API key): + python test_sora_node.py --test-client --api-key YOUR_KEY +""" + +import json +import os +import sys +import unittest +from unittest.mock import Mock, patch, MagicMock +from urllib.error import HTTPError + +# 导入节点代码 +from sora_node import SoraClient, SoraTextToVideo, SoraAPIError, DEFAULT_SORA_BASE_URL + + +class TestSoraClient(unittest.TestCase): + """测试 SoraClient 类""" + + def test_client_init_with_api_key(self): + """测试客户端初始化""" + client = SoraClient(api_key="test-key") + self.assertEqual(client.api_key, "test-key") + self.assertEqual(client.base_url, DEFAULT_SORA_BASE_URL) + + def test_client_init_without_api_key(self): + """测试缺少 API key 时抛出错误""" + with self.assertRaises(ValueError): + SoraClient(api_key="") + + @patch('sora_node.urllib.request.urlopen') + def test_create_job_success(self, mock_urlopen): + """测试创建任务成功""" + # Mock HTTP 响应 + mock_response = MagicMock() + mock_response.read.return_value = b'{"id": "job-123", "status": "submitted"}' + mock_urlopen.return_value.__enter__.return_value = mock_response + + client = SoraClient(api_key="test-key") + result = client.create_job({"prompt": "test prompt"}) + + self.assertEqual(result["id"], "job-123") + self.assertEqual(result["status"], "submitted") + + @patch('sora_node.urllib.request.urlopen') + def test_create_job_error(self, mock_urlopen): + """测试创建任务失败""" + # Mock HTTP 错误 + mock_error = HTTPError("http://test.com", 400, "Bad Request", {}, None) + mock_error.read.return_value = b'{"error": "Invalid prompt"}' + mock_urlopen.side_effect = mock_error + + client = SoraClient(api_key="test-key") + with self.assertRaises(SoraAPIError): + client.create_job({"prompt": "test"}) + + +class TestSoraTextToVideo(unittest.TestCase): + """测试 SoraTextToVideo 节点类""" + + def test_input_types(self): + """测试节点输入类型定义""" + node = SoraTextToVideo() + input_types = node.INPUT_TYPES() + + self.assertIn("required", input_types) + self.assertIn("optional", input_types) + self.assertIn("prompt", input_types["required"]) + + def test_resolve_api_key_from_env(self): + """测试从环境变量解析 API key""" + # 测试 SORA_API_KEY + os.environ["SORA_API_KEY"] = "env-key-123" + from sora_node import _resolve_api_key + key = _resolve_api_key(None) + self.assertEqual(key, "env-key-123") + + # 测试 OPENAI_API_KEY(fallback) + del os.environ["SORA_API_KEY"] + os.environ["OPENAI_API_KEY"] = "openai-key-456" + key = _resolve_api_key(None) + self.assertEqual(key, "openai-key-456") + + # 清理 + if "OPENAI_API_KEY" in os.environ: + del os.environ["OPENAI_API_KEY"] + + @patch('sora_node.SoraClient') + def test_generate_basic(self, mock_client_class): + """测试基本的生成功能""" + # Mock 客户端 + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + # Mock API 响应 + mock_client.create_job.return_value = { + "id": "test-job-123", + "status": "submitted" + } + + node = SoraTextToVideo() + job_id, status, result = node.generate( + prompt="test prompt", + api_key="test-key", + wait_for_result=False + ) + + self.assertEqual(job_id, "test-job-123") + self.assertEqual(status, "submitted") + result_data = json.loads(result) + self.assertEqual(result_data["job_id"], "test-job-123") + + @patch('sora_node.SoraClient') + def test_generate_with_polling(self, mock_client_class): + """测试带轮询的生成""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + # Mock 创建任务 + mock_client.create_job.return_value = { + "id": "test-job-456", + "status": "processing" + } + + # Mock 轮询响应 + mock_client.fetch_job.return_value = { + "id": "test-job-456", + "status": "succeeded", + "result_url": "https://example.com/video.mp4" + } + + node = SoraTextToVideo() + job_id, status, result = node.generate( + prompt="test prompt", + api_key="test-key", + wait_for_result=True, + max_wait_seconds=10.0, + poll_interval=1.0 + ) + + self.assertEqual(job_id, "test-job-456") + self.assertEqual(status, "succeeded") + # 验证轮询被调用 + mock_client.fetch_job.assert_called() + + def test_extract_video_url(self): + """测试视频 URL 提取""" + node = SoraTextToVideo() + + # 测试 result_url + response1 = {"result_url": "https://example.com/video1.mp4"} + url1 = node._extract_video_url(response1) + self.assertEqual(url1, "https://example.com/video1.mp4") + + # 测试 outputs 数组 + response2 = { + "outputs": [{"url": "https://example.com/video2.mp4"}] + } + url2 = node._extract_video_url(response2) + self.assertEqual(url2, "https://example.com/video2.mp4") + + # 测试空响应 + response3 = {} + url3 = node._extract_video_url(response3) + self.assertIsNone(url3) + + +def test_node_mappings(): + """测试节点映射是否正确""" + from sora_node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + + assert "SoraTextToVideo" in NODE_CLASS_MAPPINGS + assert NODE_CLASS_MAPPINGS["SoraTextToVideo"] == SoraTextToVideo + assert "SoraTextToVideo" in NODE_DISPLAY_NAME_MAPPINGS + print("✓ 节点映射测试通过") + + +def run_basic_tests(): + """运行基本功能测试(不需要真实 API)""" + print("=" * 60) + print("运行基本功能测试...") + print("=" * 60) + + # 测试节点映射 + test_node_mappings() + + # 测试输入类型 + node = SoraTextToVideo() + input_types = node.INPUT_TYPES() + assert "required" in input_types + assert "prompt" in input_types["required"] + print("✓ 节点输入类型测试通过") + + # 测试 API key 解析 + os.environ["SORA_API_KEY"] = "test-key-123" + from sora_node import _resolve_api_key + key = _resolve_api_key(None) + assert key == "test-key-123" + del os.environ["SORA_API_KEY"] + print("✓ API key 解析测试通过") + + print("\n所有基本测试通过!") + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="测试 Sora 自定义节点") + parser.add_argument("--test-class", action="store_true", help="运行类测试") + parser.add_argument("--test-client", action="store_true", help="测试客户端(需要真实 API key)") + parser.add_argument("--api-key", type=str, help="API key(用于客户端测试)") + parser.add_argument("--unittest", action="store_true", help="运行完整的 unittest 测试套件") + + args = parser.parse_args() + + if args.unittest: + # 运行完整的 unittest 测试 + unittest.main(argv=[''], exit=False, verbosity=2) + elif args.test_class: + # 运行基本类测试 + run_basic_tests() + elif args.test_client and args.api_key: + # 测试真实 API 客户端(需要网络连接和有效 API key) + print("测试真实 API 客户端...") + print("注意:这需要有效的 API key 和网络连接") + try: + client = SoraClient(api_key=args.api_key) + # 这里可以添加真实的 API 调用测试 + print("客户端初始化成功") + except Exception as e: + print(f"客户端测试失败: {e}") + else: + # 默认运行基本测试 + run_basic_tests() + print("\n提示:") + print(" - 运行完整测试: python test_sora_node.py --unittest") + print(" - 运行基本测试: python test_sora_node.py --test-class") + print(" - 测试真实 API: python test_sora_node.py --test-client --api-key YOUR_KEY") +