@aiyiran/myclaw 1.0.243 → 1.0.245

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -290,17 +290,27 @@
290
290
  });
291
291
  }
292
292
 
293
+ function fetchArtifactsFromServerAPI(wsPrefix) {
294
+ var url = window.location.origin + '/cmd/api/artifacts?workspace=' + encodeURIComponent(wsPrefix) + '&t=' + Date.now();
295
+ return fetch(url).then(function (res) {
296
+ if (!res.ok) throw new Error('HTTP ' + res.status);
297
+ return res.json();
298
+ });
299
+ }
300
+
293
301
  function fetchArtifacts(contentEl) {
302
+ // cachedConfig 未就绪时跳过,等 initConfig 完成后的 startPolling 重试
303
+ if (!cachedConfig) return;
304
+
294
305
  var wsPrefix = getWorkspaceId();
295
306
  var fetcher;
296
307
 
297
308
  if (envInfo && envInfo.remote) {
298
- // 远程环境 → 走 CDN
299
- fetcher = fetchArtifactsFromCDN(wsPrefix);
309
+ // 远程服务器 → 走 /cmd/api(服务器直接提供 JSON)
310
+ fetcher = fetchArtifactsFromServerAPI(wsPrefix);
300
311
  } else {
301
- // 本地环境 → 优先本地 API,失败降级 CDN
302
- fetcher = fetchArtifactsFromLocalAPI(wsPrefix)
303
- .catch(function () { return fetchArtifactsFromCDN(wsPrefix); });
312
+ // 本地环境 → CDN
313
+ fetcher = fetchArtifactsFromCDN(wsPrefix);
304
314
  }
305
315
 
306
316
  fetcher
@@ -27,6 +27,7 @@
27
27
  var committedText = ""; // 已经提交到 textarea 的文字(上一轮累积)
28
28
  var cursorOffset = 0; // 录音开始时光标在 textarea 中的位置
29
29
  var injected = false;
30
+ var stopping = false; // 正在等待最终识别结果(stopVoice 的 2 秒窗口)
30
31
 
31
32
  // ═══ 1. 右下角版本标签(点击测试麦克风) ═══
32
33
  function createVersionBar() {
@@ -323,7 +324,8 @@
323
324
  '</svg>',
324
325
  ].join("");
325
326
 
326
- btn.addEventListener("click", function () {
327
+ btn.addEventListener("click", function () {
328
+ console.log("[myclaw-voice] 按钮点击, recording=", recording);
327
329
  if (recording) {
328
330
  stopVoice();
329
331
  } else {
@@ -367,8 +369,8 @@
367
369
 
368
370
  voice = new window.VoiceInput({
369
371
  onResult: function (text) {
370
- // 如果用户已点停止,忽略异步返回的残留结果(防止文字重复)
371
- if (!recording) return;
372
+ // 完全停止后才忽略;stopping 期间(2秒等待窗口)仍允许写入
373
+ if (!recording && !stopping) return;
372
374
  // 讯飞实时返回识别文字,替换到光标位置
373
375
  pendingText = text;
374
376
  updateTextAtCursor(pendingText);
@@ -377,7 +379,7 @@
377
379
  onStatusChange: function (oldStatus, newStatus) {
378
380
  console.log("[myclaw-voice] \u72b6\u6001:", oldStatus, "->", newStatus);
379
381
 
380
- if (newStatus === "idle" && recording) {
382
+ if (newStatus === "idle" && recording && !stopping) {
381
383
  // 讯飞 60 秒断开,但用户没有点停止 → 自动重连
382
384
  // 把当前识别的文字提交,并更新光标位置
383
385
  committedText = getTextareaValue();
@@ -435,23 +437,54 @@
435
437
  console.log("[myclaw-voice] \u5f00\u59cb\u5f55\u97f3\uff0c\u5149\u6807\u4f4d\u7f6e:", cursorOffset);
436
438
  }
437
439
 
438
- function stopVoice() {
439
- // 先关标志位,阻止 onResult 异步回调继续写入(核心防重复)
440
+
441
+ /**
442
+ * 语音录入结束时,等待 2 秒后关闭录音资源
443
+ * @param {Function} [onDone] - 等待完成后执行的回调(如发送)
444
+ */
445
+ var voiceStopTimer = null;
446
+
447
+ function stopVoice(onDone) {
448
+ console.log("[myclaw-voice] stopVoice called, recording=", recording, "onDone=", onDone ? "yes" : "no");
449
+ if (!recording) {
450
+ console.log("[myclaw-voice] stopVoice early return — not recording");
451
+ return;
452
+ }
453
+
454
+ // 进入 stopping 态:UI 立即更新,但 onResult 仍允许在 2 秒内写入文字
455
+ stopping = true;
440
456
  recording = false;
441
457
  updateButtonUI();
458
+ console.log("[myclaw-voice] stopping=true, UI updated, starting 2s timer...");
442
459
 
443
- // 立即快照当前 textarea 值作为最终文字
444
- var finalText = getTextareaValue();
445
-
446
- if (voice) {
447
- voice.stop();
460
+ // 延迟 2 秒后关闭录音资源(等讯飞把剩余识别结果全部推过来)
461
+ if (voiceStopTimer) {
462
+ console.log("[myclaw-voice] clearing previous timer");
463
+ clearTimeout(voiceStopTimer);
448
464
  }
465
+ voiceStopTimer = setTimeout(function () {
466
+ voiceStopTimer = null;
467
+ stopping = false;
468
+ console.log("[myclaw-voice] 2s timer fired, closing resources...");
469
+
470
+ // 快照当前 textarea 值(2 秒内 onResult 可能已更新)
471
+ var finalText = getTextareaValue();
472
+ console.log("[myclaw-voice] finalText:", JSON.stringify(finalText.substring(0, 50)));
473
+ if (voice) {
474
+ console.log("[myclaw-voice] calling voice.stop()");
475
+ voice.stop();
476
+ }
477
+ committedText = finalText;
478
+ pendingText = "";
449
479
 
450
- // 用快照覆盖,确保后续异步返回不影响
451
- committedText = finalText;
452
- pendingText = "";
480
+ // 等待完成后执行回调(如发送)
481
+ if (onDone) {
482
+ console.log("[myclaw-voice] executing onDone callback...");
483
+ onDone();
484
+ }
453
485
 
454
- console.log("[myclaw-voice] \u505c\u6b62\u5f55\u97f3");
486
+ console.log("[myclaw-voice] 停止录音完成");
487
+ }, 2000);
455
488
  }
456
489
 
457
490
  // ═══ 5. DOM 注入 ═══
@@ -522,6 +555,28 @@
522
555
  }
523
556
  // ═══ 6. 拦截发送按钮 ═══
524
557
 
558
+ /**
559
+ * 拦截 Enter 键:语音态下按回车 → 等待 2 秒后发送
560
+ */
561
+ function hookVoiceEnter() {
562
+ document.addEventListener("keydown", function (e) {
563
+ if (e.key !== "Enter") return;
564
+ if (!recording) return;
565
+
566
+ // 语音录入中,无论焦点在哪里(textarea 或语音按钮),Enter 统一触发"停止并发送"
567
+ e.preventDefault();
568
+ e.stopPropagation();
569
+
570
+ console.log("[myclaw-voice] Enter按下, recording=", recording);
571
+ stopVoice(function () {
572
+ console.log("[myclaw-voice] Enter stopVoice callback firing...");
573
+ var sendBtn = document.querySelector("button.chat-send-btn, button[title=\"Send\"]");
574
+ if (sendBtn) sendBtn.click();
575
+ });
576
+
577
+ }, true);
578
+ }
579
+
525
580
  var sendHooked = false;
526
581
 
527
582
  function hookSendButton() {
@@ -536,9 +591,17 @@
536
591
  var text = getTextareaValue();
537
592
  if (!text || !text.trim()) return; // 空文字不处理
538
593
 
539
- // 1) 停止语音输入
594
+ // 1) 停止语音输入(等待 2 秒后关闭,关闭后触发发送)
540
595
  if (recording) {
541
- stopVoice();
596
+ e.preventDefault();
597
+ e.stopPropagation();
598
+ console.log("[myclaw-voice] 发送按钮点击(语音态), recording=", recording);
599
+ stopVoice(function () {
600
+ console.log("[myclaw-voice] 发送按钮 stopVoice callback firing...");
601
+ var sendBtn = document.querySelector("button.chat-send-btn, button[title=\"Send\"]");
602
+ if (sendBtn) sendBtn.click();
603
+ });
604
+ return;
542
605
  }
543
606
 
544
607
  // 2) 复制到剪贴板
@@ -546,7 +609,6 @@
546
609
  navigator.clipboard.writeText(text).then(function () {
547
610
  console.log("[myclaw-send] 📋 已复制到剪贴板:", text.substring(0, 50) + (text.length > 50 ? "..." : ""));
548
611
  }).catch(function () {
549
- // fallback: 老方法
550
612
  fallbackCopy(text);
551
613
  });
552
614
  } catch (ex) {
@@ -554,9 +616,8 @@
554
616
  }
555
617
 
556
618
  // 3) 让原生 click 继续走(发送消息)
557
- // 不 preventDefault,不 stopPropagation
558
619
 
559
- // 4) 延迟清空 textarea(等原生 handler 读完值后再清)
620
+ // 4) 延迟清空 textarea
560
621
  setTimeout(function () {
561
622
  setTextareaValue("");
562
623
  committedText = "";
@@ -853,7 +914,7 @@
853
914
  { label: "\uD83D\uDCAC \u6DFB\u52A0\u5BF9\u8BDD", desc: "\u6253\u5F00\u5DF2\u6709\u4F19\u4F34\u7684\u5BF9\u8BDD\u7A97\u53E3", hasInput: true, inputTitle: "\u6DFB\u52A0\u5BF9\u8BDD", placeholder: "\u8F93\u5165\u4F19\u4F34\u540D\u79F0\uFF0C\u5982 kakaxi", hint: "\u8F93\u5165\u4F60\u7684\u4F19\u4F34\u7684\u540D\u79F0\uFF08\u82F1\u6587\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u8FDE\u5B57\u7B26\uFF09\uFF0C\u70B9\u51FB\u540E\u4F1A\u6253\u5F00\u5BF9\u8BDD\u7A97\u53E3", cmd: "mc tui {name}", color: "#10b981" },
854
915
  { label: "\uD83D\uDE80 \u5347\u7EA7", desc: "\u5347\u7EA7 myclaw \u5230\u6700\u65B0\u7248\u672C", hasInput: false, cmd: "mc up", color: "#8b5cf6" },
855
916
  { label: "\uD83D\uDD04 \u91CD\u542F", desc: "\u91CD\u542F\u670D\u52A1\uFF0C\u4FEE\u590D\u5927\u591A\u6570\u95EE\u9898", hasInput: false, cmd: "mc restart", color: "#ef4444" },
856
- { label: "\uD83E\uDD1D \u65B0\u4F19\u4F34", desc: "\u521B\u5EFA\u4E00\u4E2A\u65B0\u7684 AI \u4F19\u4F34", hasInput: true, inputTitle: "\u65B0\u5EFA\u4F19\u4F34", placeholder: "\u8F93\u5165\u65B0\u4F19\u4F34\u540D\u79F0\uFF0C\u5982 my-cat", hint: "\u7ED9\u4F60\u7684\u65B0 AI \u4F19\u4F34\u8D77\u4E2A\u540D\u5B57\uFF08\u82F1\u6587\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u8FDE\u5B57\u7B26\uFF09\uFF0C\u70B9\u51FB\u540E\u4F1A\u81EA\u52A8\u521B\u5EFA", cmd: "mc tui {name}", color: "#3b82f6" },
917
+ { label: "\uD83E\uDD1D \u65B0\u4F19\u4F34", desc: "\u521B\u5EFA\u4E00\u4E2A\u65B0\u7684 AI \u4F19\u4F34", hasInput: true, inputTitle: "\u65B0\u5EFA\u4F19\u4F34", placeholder: "\u8F93\u5165\u65B0\u4F19\u4F34\u540D\u79F0\uFF0C\u5982 my-cat", hint: "\u7ED9\u4F60\u7684\u65B0 AI \u4F19\u4F34\u8D77\u4E2A\u540D\u5B57\uFF08\u82F1\u6587\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u8FDE\u5B57\u7B26\uFF09\uFF0C\u70B9\u51FB\u540E\u4F1A\u81EA\u52A8\u521B\u5EFA", cmd: "mc new {name}", color: "#3b82f6" },
857
918
  ];
858
919
 
859
920
  btns.forEach(function (item) {
@@ -908,6 +969,175 @@
908
969
  form.appendChild(row);
909
970
  });
910
971
 
972
+ // ── 删除伙伴按钮 ──
973
+ var delRow = document.createElement("div");
974
+ delRow.style.cssText = [
975
+ "padding:10px 14px",
976
+ "background:#252536",
977
+ "border-radius:6px",
978
+ "cursor:pointer",
979
+ "transition:background 0.15s",
980
+ "display:flex",
981
+ "align-items:center",
982
+ "gap:10px",
983
+ ].join(";");
984
+ delRow.onmouseenter = function () { delRow.style.background = "#2f2f4a"; };
985
+ delRow.onmouseleave = function () { delRow.style.background = "#252536"; };
986
+
987
+ var delBar = document.createElement("div");
988
+ delBar.style.cssText = "width:3px;height:28px;border-radius:2px;background:#ef4444;flex-shrink:0;";
989
+ delRow.appendChild(delBar);
990
+
991
+ var delInfo = document.createElement("div");
992
+ delInfo.style.cssText = "flex:1;display:flex;flex-direction:column;gap:2px;";
993
+ var delName = document.createElement("div");
994
+ delName.textContent = "\uD83D\uDDD1 \u5220\u9664\u4F19\u4F34";
995
+ delName.style.cssText = "font-size:13px;font-weight:bold;color:#ef4444;";
996
+ delInfo.appendChild(delName);
997
+ var delDesc = document.createElement("div");
998
+ delDesc.textContent = "\u5220\u9664\u4E00\u4E2A AI \u4F19\u4F34\uFF0C\u6B64\u64CD\u4F5C\u65E0\u6CD5\u6062\u590D";
999
+ delDesc.style.cssText = "font-size:11px;color:#888;";
1000
+ delInfo.appendChild(delDesc);
1001
+ delRow.appendChild(delInfo);
1002
+
1003
+ var delArrow = document.createElement("div");
1004
+ delArrow.textContent = "\u25B6";
1005
+ delArrow.style.cssText = "color:#555;font-size:10px;";
1006
+ delRow.appendChild(delArrow);
1007
+
1008
+ delRow.onclick = function () {
1009
+ showDeleteConfirm();
1010
+ };
1011
+
1012
+ form.appendChild(delRow);
1013
+
1014
+ // 删除伙伴 - 双重确认弹框
1015
+ function showDeleteConfirm() {
1016
+ var mask = document.createElement("div");
1017
+ mask.style.cssText = [
1018
+ "position:fixed",
1019
+ "top:0;left:0;width:100vw;height:100vh",
1020
+ "background:rgba(0,0,0,0.3)",
1021
+ "z-index:999999",
1022
+ "display:flex",
1023
+ "align-items:center",
1024
+ "justify-content:center",
1025
+ "animation:myclaw-fade-in 0.15s ease",
1026
+ ].join(";");
1027
+
1028
+ var box = document.createElement("div");
1029
+ box.style.cssText = [
1030
+ "width:360px",
1031
+ "background:#1e1e2e",
1032
+ "border-radius:8px",
1033
+ "overflow:hidden",
1034
+ "box-shadow:0 8px 32px rgba(0,0,0,0.5)",
1035
+ ].join(";");
1036
+
1037
+ // 标题
1038
+ var h = document.createElement("div");
1039
+ h.style.cssText = "padding:10px 14px;background:#ef4444;color:#fff;font-size:13px;display:flex;justify-content:space-between;align-items:center;";
1040
+ h.innerHTML = '<span>\uD83D\uDDD1 \u5220\u9664\u4F19\u4F34</span>';
1041
+ var x = document.createElement("span");
1042
+ x.textContent = "\u2715";
1043
+ x.style.cssText = "cursor:pointer;padding:2px 6px;border-radius:3px;";
1044
+ x.onclick = function () { mask.remove(); };
1045
+ h.appendChild(x);
1046
+ box.appendChild(h);
1047
+
1048
+ // body
1049
+ var body = document.createElement("div");
1050
+ body.style.cssText = "padding:16px;display:flex;flex-direction:column;gap:12px;";
1051
+
1052
+ var hint1 = document.createElement("div");
1053
+ hint1.textContent = "\u8BF7\u8F93\u5165\u8981\u5220\u9664\u7684\u4F19\u4F34 ID\uFF1A";
1054
+ hint1.style.cssText = "font-size:12px;color:#888;";
1055
+ body.appendChild(hint1);
1056
+
1057
+ var input1 = document.createElement("input");
1058
+ input1.type = "text";
1059
+ input1.placeholder = "\u4F19\u4F34 ID";
1060
+ input1.style.cssText = "padding:8px 10px;background:#252536;border:1px solid #3d3d5c;border-radius:4px;color:#cdd6f4;font-size:13px;font-family:monospace;outline:none;";
1061
+ body.appendChild(input1);
1062
+
1063
+ var warn = document.createElement("div");
1064
+ warn.textContent = "\u26A0 \u6B64\u64CD\u4F5C\u65E0\u6CD5\u6062\u590D\uFF0C\u786E\u8BA4\u540E\u5C06\u6C38\u4E45\u5220\u9664\uFF01";
1065
+ warn.style.cssText = "font-size:11px;color:#ef4444;padding:8px;background:rgba(239,68,68,0.1);border-radius:4px;";
1066
+ body.appendChild(warn);
1067
+
1068
+ var confirmHint = document.createElement("div");
1069
+ confirmHint.textContent = '\u8BF7\u8F93\u5165 "YES" \u786E\u8BA4\u5220\u9664\uFF1A';
1070
+ confirmHint.style.cssText = "font-size:12px;color:#888;";
1071
+ confirmHint.style.display = "none";
1072
+ body.appendChild(confirmHint);
1073
+
1074
+ var input2 = document.createElement("input");
1075
+ input2.type = "text";
1076
+ input2.placeholder = "YES";
1077
+ input2.style.cssText = "padding:8px 10px;background:#252536;border:1px solid #3d3d5c;border-radius:4px;color:#cdd6f4;font-size:13px;font-family:monospace;outline:none;";
1078
+ input2.style.display = "none";
1079
+ body.appendChild(input2);
1080
+
1081
+ var submitBtn = document.createElement("button");
1082
+ submitBtn.textContent = "\u7EE7\u7EED";
1083
+ submitBtn.style.cssText = "padding:8px 16px;background:#ef4444;border:none;border-radius:4px;color:#fff;font-size:12px;font-family:monospace;cursor:pointer;";
1084
+ body.appendChild(submitBtn);
1085
+
1086
+ var cancelBtn = document.createElement("button");
1087
+ cancelBtn.textContent = "\u53D6\u6D88";
1088
+ cancelBtn.style.cssText = "padding:8px 16px;background:#3d3d5c;border:none;border-radius:4px;color:#cdd6f4;font-size:12px;font-family:monospace;cursor:pointer;";
1089
+ body.appendChild(cancelBtn);
1090
+
1091
+ box.appendChild(body);
1092
+ mask.appendChild(box);
1093
+ document.body.appendChild(mask);
1094
+
1095
+ input1.focus();
1096
+
1097
+ var agentId = "";
1098
+ submitBtn.onclick = function () {
1099
+ if (!agentId) {
1100
+ // 第一步:输入 agent ID
1101
+ agentId = input1.value.trim();
1102
+ if (!agentId) {
1103
+ input1.style.borderColor = "#ef4444";
1104
+ input1.focus();
1105
+ return;
1106
+ }
1107
+ // 显示第二步确认
1108
+ input1.style.display = "none";
1109
+ hint1.style.display = "none";
1110
+ confirmHint.style.display = "block";
1111
+ input2.style.display = "block";
1112
+ submitBtn.textContent = "\u786E\u8BA4\u5220\u9664";
1113
+ input2.value = "";
1114
+ input2.focus();
1115
+ } else {
1116
+ // 第二步:确认删除
1117
+ var confirmVal = input2.value.trim();
1118
+ if (confirmVal.toUpperCase() !== "YES") {
1119
+ input2.style.borderColor = "#ef4444";
1120
+ input2.focus();
1121
+ return;
1122
+ }
1123
+ submitBtn.disabled = true;
1124
+ submitBtn.textContent = "\u6267\u884C\u4E2D...";
1125
+ runCommand("openclaw agents delete " + agentId + " --force");
1126
+ setTimeout(function () {
1127
+ mask.remove();
1128
+ }, 1000);
1129
+ }
1130
+ };
1131
+
1132
+ cancelBtn.onclick = function () {
1133
+ mask.remove();
1134
+ };
1135
+
1136
+ input2.onkeydown = function (e) {
1137
+ if (e.key === "Enter") submitBtn.click();
1138
+ };
1139
+ }
1140
+
911
1141
  box.appendChild(header);
912
1142
  box.appendChild(form);
913
1143
  overlay.appendChild(box);
@@ -939,6 +1169,9 @@
939
1169
  // 初始化 VoiceInput SDK
940
1170
  initVoice();
941
1171
 
1172
+ // 拦截语音态 Enter 键
1173
+ hookVoiceEnter();
1174
+
942
1175
  // 持续监听 DOM 变化,确保按钮始终在
943
1176
  new MutationObserver(function () {
944
1177
  if (!document.querySelector("#myclaw-voice-btn")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiyiran/myclaw",
3
- "version": "1.0.243",
3
+ "version": "1.0.245",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: yiran-skill-media
3
- description: 统一多媒体生成技能。支持图片和音乐生成,按资源类型自动路由到最优 provider,支持主备切换。资源生成规范:所有生成的资源必须存放在当前工作目录下,调用时通过 --output-dir 传入当前工作目录的绝对路径,通过 --name 传入资源的中文名称。
3
+ description: 统一多媒体生成技能。支持图片、音乐、文生视频和图生视频,按资源类型自动路由到最优 provider,支持多级降级。资源生成规范:所有生成的资源必须存放在当前工作目录下,调用时通过 --output-dir 传入当前工作目录的绝对路径,通过 --name 传入资源的中文名称。
4
4
  ---
5
5
 
6
6
  # 统一多媒体生成
@@ -12,10 +12,6 @@ description: 统一多媒体生成技能。支持图片和音乐生成,按资
12
12
 
13
13
  **所有生成的资源文件必须存放在当前工作目录下。** 不允许省略,不允许猜测路径。
14
14
 
15
- 例如:
16
- - 你当前在 `/root/.openclaw/workspace` → 传入 `--output-dir /root/.openclaw/workspace`
17
- - 生成一张日落图片 → 传入 `--name 日落风景`
18
-
19
15
  ## 一键脚本
20
16
 
21
17
  ```bash
@@ -24,6 +20,12 @@ description: 统一多媒体生成技能。支持图片和音乐生成,按资
24
20
 
25
21
  # 音乐生成
26
22
  ./music.sh --output-dir "$(pwd)" --name 开场音乐 "epic opening" [--instrumental]
23
+
24
+ # 文生视频 (Text-to-Video)
25
+ ./video.sh --output-dir "$(pwd)" --name 日落延时 "a sunset timelapse" [--duration 6] [--resolution 768P] [--aspect-ratio 16:9]
26
+
27
+ # 图生视频 (Image-to-Video)
28
+ ./i2v.sh --output-dir "$(pwd)" --name 猫咪跑步 --first-frame-image "https://example.com/cat.jpg" "cat running toward camera"
27
29
  ```
28
30
 
29
31
  ## 参数说明
@@ -32,40 +34,73 @@ description: 统一多媒体生成技能。支持图片和音乐生成,按资
32
34
 
33
35
  | 参数 | 必填 | 说明 |
34
36
  |------|------|------|
35
- | `--output-dir` | 是 | 输出目录的绝对路径,传入当前工作目录 |
37
+ | `--output-dir` | 是 | 输出目录的绝对路径 |
36
38
  | `--name` | 是 | 资源中文名称(如:日落风景、产品封面) |
37
39
  | `prompt` | 是 | 图片描述 |
38
- | `--aspect-ratio` | 否 | 比例,默认 1:1。可选:16:9, 9:16, 4:3 等 |
40
+ | `--aspect-ratio` | 否 | 比例,默认 16:9。可选:1:1, 16:9, 9:16, 4:3, 3:4 等 |
39
41
 
40
42
  ### music.sh
41
43
 
42
44
  | 参数 | 必填 | 说明 |
43
45
  |------|------|------|
44
- | `--output-dir` | 是 | 输出目录的绝对路径,传入当前工作目录 |
45
- | `--name` | 是 | 资源中文名称(如:开场音乐、背景配乐) |
46
+ | `--output-dir` | 是 | 输出目录的绝对路径 |
47
+ | `--name` | 是 | 资源中文名称 |
46
48
  | `prompt` | 是 | 音乐风格/情绪描述 |
47
49
  | `--lyrics` | 否 | 歌词文本 |
48
50
  | `--instrumental` | 否 | 纯音乐模式 |
49
51
 
52
+ ### video.sh — 文生视频
53
+
54
+ | 参数 | 必填 | 说明 |
55
+ |------|------|------|
56
+ | `--output-dir` | 是 | 输出目录的绝对路径 |
57
+ | `--name` | 是 | 资源中文名称 |
58
+ | `prompt` | 是 | 视频内容描述(支持运镜指令如 `[推进]`、`[左摇]`) |
59
+ | `--duration` | 否 | 视频时长(秒),默认 6,可选 6 或 10 |
60
+ | `--resolution` | 否 | MiniMax 分辨率:768P/1080P(默认 768P) |
61
+ | `--aspect-ratio` | 否 | 即梦降级时使用:16:9/9:16/4:3/1:1(默认 16:9) |
62
+
63
+ ### i2v.sh — 图生视频
64
+
65
+ | 参数 | 必填 | 说明 |
66
+ |------|------|------|
67
+ | `--output-dir` | 是 | 输出目录的绝对路径 |
68
+ | `--name` | 是 | 资源中文名称 |
69
+ | `--first-frame-image` | 是 | 首帧图片的公网 URL(JPG/PNG/WebP,<20MB) |
70
+ | `prompt` | 否 | 基于首帧图像的动作/变化描述 |
71
+ | `--duration` | 否 | 视频时长,默认 6 |
72
+ | `--resolution` | 否 | 分辨率,默认 768P |
73
+
74
+ **注意**:视频生成为异步任务,耗时较长(通常 1-5 分钟),脚本会自动轮询等待完成。
75
+
50
76
  ## 架构
51
77
 
52
78
  ```
53
- image.sh / music.sh 智能体调用的薄壳入口
54
- generate.py 统一路由调度(主备切换)
55
- config.json provider 配置中心(key、模型、地址)
56
- providers/ provider 适配器
57
- minimax_image.py MiniMax 图片(主)
58
- vapi_image.py VAPI 图片(备)
59
- minimax_music.py → MiniMax 音乐(主)
79
+ image.sh 图片生成入口
80
+ music.sh 音乐生成入口
81
+ video.sh 文生视频入口 (Text-to-Video)
82
+ i2v.sh 图生视频入口 (Image-to-Video)
83
+ generate.py 统一路由调度(优先级数组,依次尝试)
84
+ config.json provider 配置中心(优先级列表)
85
+ providers/
86
+ vapi_image.py → VAPI 图片
87
+ minimax_image.py → MiniMax 图片
88
+ jimeng_image.py → 即梦 图片 4.0(异步)
89
+ minimax_music.py → MiniMax 音乐
90
+ minimax_video.py → MiniMax 视频(文生+图生)
91
+ jimeng_video.py → 即梦 视频 3.0(异步)
60
92
  ```
61
93
 
62
- ## Provider 配置
94
+ ## Provider 配置(优先级列表)
63
95
 
64
- 编辑 `config.json` 可切换主备 provider、更换模型或 API Key。
96
+ `config.json` 中每个资源类型是一个数组,按优先级从高到低排列。
97
+ 第一个失败自动尝试下一个,直到成功或全部失败。
65
98
 
66
99
  当前配置:
67
- - **图片**:MiniMax (image-01) → fallback VAPI (nano-banana-pro)
68
- - **音乐**:MiniMax (music-2.6)
100
+ - **图片**:① VAPI (nano-banana-2) → ② MiniMax (image-01) → 即梦 (jimeng_t2i_v40)
101
+ - **音乐**:① MiniMax (music-2.6)
102
+ - **文生视频**:① MiniMax (MiniMax-Hailuo-2.3) → ② 即梦 (jimeng_t2v_v30)
103
+ - **图生视频**:① MiniMax (MiniMax-Hailuo-2.3-Fast)
69
104
 
70
105
  ## 详细 API 参考
71
106
 
@@ -1,26 +1,53 @@
1
1
  {
2
2
  "output_dir": "media",
3
- "image": {
4
- "primary": {
3
+ "image": [
4
+ {
5
+ "provider": "jimeng_image",
6
+ "model": "jimeng_t2i_v40",
7
+ "access_key": "AKLTYjZkY2FiZmZkYWU5NDkxNmEwZjNlYTRjNmRlZmYwNDI",
8
+ "secret_key": "TjJGbU5HVTBZek14TnpFeE5HWTVOVGhsTURRNE9XRXhNR1JoTm1FeVlqaw=="
9
+ },
10
+ {
5
11
  "provider": "vapi_image",
6
- "model": "nano-banana-pro",
12
+ "model": "nano-banana-2",
7
13
  "base_url": "https://api.v3.cm/v1",
8
14
  "api_key": "sk-PXPUzqllWKJy2oj011Df510242264219Ba21093e3d2b2335"
9
15
  },
10
- "fallback": {
16
+ {
11
17
  "provider": "minimax_image",
12
18
  "model": "image-01",
13
19
  "base_url": "https://api.minimaxi.com/v1",
14
20
  "api_key": "sk-cp-DC5lWd2Stt9CBFzLIT2awP4K-ZEn5AkYwjl3Cdj-mIBmgjxod518F2LaVF2L9c35Wv5-Eox0F1ctJD5vXtB9p3OmxoWLd9ge9zIUIMrCVuqBYdL_s6kb8Qs"
15
21
  }
16
- },
17
- "music": {
18
- "primary": {
22
+ ],
23
+ "music": [
24
+ {
19
25
  "provider": "minimax_music",
20
26
  "model": "music-2.6",
21
27
  "base_url": "https://api.minimaxi.com/v1",
22
28
  "api_key": "sk-cp-DC5lWd2Stt9CBFzLIT2awP4K-ZEn5AkYwjl3Cdj-mIBmgjxod518F2LaVF2L9c35Wv5-Eox0F1ctJD5vXtB9p3OmxoWLd9ge9zIUIMrCVuqBYdL_s6kb8Qs"
29
+ }
30
+ ],
31
+ "video": [
32
+ {
33
+ "provider": "minimax_video",
34
+ "model": "MiniMax-Hailuo-2.3",
35
+ "base_url": "https://api.minimaxi.com/v1",
36
+ "api_key": "sk-cp-DC5lWd2Stt9CBFzLIT2awP4K-ZEn5AkYwjl3Cdj-mIBmgjxod518F2LaVF2L9c35Wv5-Eox0F1ctJD5vXtB9p3OmxoWLd9ge9zIUIMrCVuqBYdL_s6kb8Qs"
23
37
  },
24
- "fallback": null
25
- }
38
+ {
39
+ "provider": "jimeng_video",
40
+ "model": "jimeng_t2v_v30",
41
+ "access_key": "AKLTYjZkY2FiZmZkYWU5NDkxNmEwZjNlYTRjNmRlZmYwNDI",
42
+ "secret_key": "TjJGbU5HVTBZek14TnpFeE5HWTVOVGhsTURRNE9XRXhNR1JoTm1FeVlqaw=="
43
+ }
44
+ ],
45
+ "i2v": [
46
+ {
47
+ "provider": "minimax_video",
48
+ "model": "MiniMax-Hailuo-2.3-Fast",
49
+ "base_url": "https://api.minimaxi.com/v1",
50
+ "api_key": "sk-cp-DC5lWd2Stt9CBFzLIT2awP4K-ZEn5AkYwjl3Cdj-mIBmgjxod518F2LaVF2L9c35Wv5-Eox0F1ctJD5vXtB9p3OmxoWLd9ge9zIUIMrCVuqBYdL_s6kb8Qs"
51
+ }
52
+ ]
26
53
  }
@@ -102,9 +102,9 @@ def append_log(log_path, entry):
102
102
 
103
103
 
104
104
  def dispatch(resource_type, prompt, **kwargs):
105
- """Route to primary provider, fallback on failure.
105
+ """Route to providers sequentially as a priority array.
106
106
 
107
- Always tries primary first, then fallback if configured.
107
+ Always tries the first provider, then falls back to the next on failure.
108
108
  Returns (files, used_provider_cfg).
109
109
  """
110
110
  cfg = load_config()
@@ -112,9 +112,15 @@ def dispatch(resource_type, prompt, **kwargs):
112
112
  if not resource_cfg:
113
113
  raise ValueError(f"unknown resource type: {resource_type}")
114
114
 
115
- providers = [resource_cfg["primary"]]
116
- if resource_cfg.get("fallback"):
117
- providers.append(resource_cfg["fallback"])
115
+ # 支持旧版 dict(primary/fallback) 过渡到新版 list
116
+ if isinstance(resource_cfg, dict):
117
+ providers = [resource_cfg["primary"]]
118
+ if resource_cfg.get("fallback"):
119
+ providers.append(resource_cfg["fallback"])
120
+ elif isinstance(resource_cfg, list):
121
+ providers = resource_cfg
122
+ else:
123
+ raise ValueError(f"invalid config structure for {resource_type}")
118
124
 
119
125
  sys.path.insert(0, SCRIPT_DIR)
120
126
  from providers import get_adapter
@@ -136,16 +142,23 @@ def dispatch(resource_type, prompt, **kwargs):
136
142
 
137
143
  def main():
138
144
  parser = argparse.ArgumentParser(description="Unified media generation dispatcher")
139
- parser.add_argument("type", choices=["image", "music"], help="Resource type")
140
- parser.add_argument("prompt", help="Generation prompt")
141
- parser.add_argument("--aspect-ratio", default="1:1", help="Image aspect ratio (image only)")
145
+ parser.add_argument("type", choices=["image", "music", "video", "i2v"], help="Resource type")
146
+ parser.add_argument("prompt", nargs="?", default="", help="Generation prompt")
147
+ parser.add_argument("--aspect-ratio", default="16:9", help="Aspect ratio (image: 1:1; video: 16:9/9:16/4:3/1:1)")
142
148
  parser.add_argument("--lyrics", default=None, help="Lyrics text (music only)")
143
149
  parser.add_argument("--instrumental", action="store_true", help="Instrumental mode (music only)")
150
+ parser.add_argument("--duration", type=int, default=None, help="Video duration in seconds (video/i2v, default 6)")
151
+ parser.add_argument("--resolution", default=None, help="Video resolution: 720P/768P/1080P (video/i2v)")
152
+ parser.add_argument("--first-frame-image", default=None, help="First frame image URL (i2v only, required)")
144
153
  parser.add_argument("--output", default=None, help="Output file path")
145
154
  parser.add_argument("--output-dir", required=True, help="Absolute path to output directory (required)")
146
155
  parser.add_argument("--name", required=True, help="Resource name in Chinese (required, e.g. 日落风景)")
147
156
  args = parser.parse_args()
148
157
 
158
+ # i2v 必须有 --first-frame-image
159
+ if args.type == "i2v" and not args.first_frame_image:
160
+ parser.error("i2v (图生视频) 模式必须提供 --first-frame-image 参数")
161
+
149
162
  out_dir = ensure_output_dir(args.output_dir)
150
163
 
151
164
  # Prepare kwargs
@@ -155,6 +168,28 @@ def main():
155
168
  "aspect_ratio": args.aspect_ratio,
156
169
  }
157
170
  ext = "png"
171
+ elif args.type == "video":
172
+ # 文生视频 — 纯文本,无图片
173
+ kwargs = {
174
+ "out_dir": out_dir,
175
+ "aspect_ratio": args.aspect_ratio,
176
+ }
177
+ if args.duration:
178
+ kwargs["duration"] = args.duration
179
+ if args.resolution:
180
+ kwargs["resolution"] = args.resolution
181
+ ext = "mp4"
182
+ elif args.type == "i2v":
183
+ # 图生视频 — 必须有首帧图片
184
+ kwargs = {
185
+ "out_dir": out_dir,
186
+ "first_frame_image": args.first_frame_image,
187
+ }
188
+ if args.duration:
189
+ kwargs["duration"] = args.duration
190
+ if args.resolution:
191
+ kwargs["resolution"] = args.resolution
192
+ ext = "mp4"
158
193
  else:
159
194
  kwargs = {
160
195
  "out_dir": out_dir,
@@ -0,0 +1,47 @@
1
+ #!/bin/bash
2
+ # 图生视频入口 (Image-to-Video)
3
+ # 模型: MiniMax-Hailuo-2.3-Fast
4
+ # 用法: ./i2v.sh --output-dir /abs/path --name 中文名 --first-frame-image URL ["描述"]
5
+ set -euo pipefail
6
+
7
+ OUTPUT_DIR=""
8
+ NAME=""
9
+ PROMPT=""
10
+ DURATION=""
11
+ RESOLUTION=""
12
+ FIRST_FRAME=""
13
+
14
+ while [ $# -gt 0 ]; do
15
+ case "$1" in
16
+ --output-dir) OUTPUT_DIR="$2"; shift 2 ;;
17
+ --name) NAME="$2"; shift 2 ;;
18
+ --duration) DURATION="$2"; shift 2 ;;
19
+ --resolution) RESOLUTION="$2"; shift 2 ;;
20
+ --first-frame-image) FIRST_FRAME="$2"; shift 2 ;;
21
+ *)
22
+ if [ -z "$PROMPT" ]; then
23
+ PROMPT="$1"
24
+ fi
25
+ shift
26
+ ;;
27
+ esac
28
+ done
29
+
30
+ if [ -z "$OUTPUT_DIR" ] || [ -z "$NAME" ] || [ -z "$FIRST_FRAME" ]; then
31
+ echo "用法: ./i2v.sh --output-dir <绝对路径> --name <中文名> --first-frame-image <图片URL> [\"动作描述\"]"
32
+ echo "示例: ./i2v.sh --output-dir \$(pwd) --name 猫咪跑步 --first-frame-image https://example.com/cat.jpg \"cat running toward camera\""
33
+ exit 1
34
+ fi
35
+
36
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
37
+
38
+ ARGS=()
39
+ ARGS+=("i2v")
40
+ [ -n "$PROMPT" ] && ARGS+=("$PROMPT")
41
+ ARGS+=(--output-dir "$OUTPUT_DIR")
42
+ ARGS+=(--name "$NAME")
43
+ ARGS+=(--first-frame-image "$FIRST_FRAME")
44
+ [ -n "$DURATION" ] && ARGS+=(--duration "$DURATION")
45
+ [ -n "$RESOLUTION" ] && ARGS+=(--resolution "$RESOLUTION")
46
+
47
+ python3 "$SCRIPT_DIR/generate.py" "${ARGS[@]}"
@@ -1,11 +1,17 @@
1
1
  from .vapi_image import VAPIImageAdapter
2
2
  from .minimax_image import MiniMaxImageAdapter
3
3
  from .minimax_music import MiniMaxMusicAdapter
4
+ from .minimax_video import MiniMaxVideoAdapter
5
+ from .jimeng_video import JimengVideoAdapter
6
+ from .jimeng_image import JimengImageAdapter
4
7
 
5
8
  ADAPTERS = {
6
9
  "vapi_image": VAPIImageAdapter(),
7
10
  "minimax_image": MiniMaxImageAdapter(),
8
11
  "minimax_music": MiniMaxMusicAdapter(),
12
+ "minimax_video": MiniMaxVideoAdapter(),
13
+ "jimeng_video": JimengVideoAdapter(),
14
+ "jimeng_image": JimengImageAdapter(),
9
15
  }
10
16
 
11
17
  def get_adapter(name: str):
@@ -13,3 +19,5 @@ def get_adapter(name: str):
13
19
  if not adapter:
14
20
  raise ValueError(f"unknown provider: {name}")
15
21
  return adapter
22
+
23
+
@@ -0,0 +1,158 @@
1
+ """即梦(Jimeng) image adapter — Volcengine SDK, async workflow.
2
+ Uses CVSync2AsyncSubmitTask → CVSync2AsyncGetResult polling.
3
+ Supports text-to-image via jimeng_t2i_v40."""
4
+ import json
5
+ import os
6
+ import sys
7
+ import time
8
+
9
+ import requests
10
+ from volcengine.visual.VisualService import VisualService
11
+
12
+
13
+ class JimengImageAdapter:
14
+ POLL_INTERVAL = 5 # 图片生成比视频快,5 秒轮询
15
+ MAX_WAIT = 120 # 最长等 2 分钟
16
+
17
+ # 比例 → 推荐的 2K 分辨率(官方文档中的推荐值)
18
+ RATIO_MAP = {
19
+ "1:1": (2048, 2048),
20
+ "4:3": (2304, 1728),
21
+ "3:4": (1728, 2304),
22
+ "3:2": (2496, 1664),
23
+ "2:3": (1664, 2496),
24
+ "16:9": (2560, 1440),
25
+ "9:16": (1440, 2560),
26
+ "21:9": (3024, 1296),
27
+ "9:21": (1296, 3024),
28
+ }
29
+
30
+ def generate(self, prompt, config, **kwargs):
31
+ access_key = config["access_key"]
32
+ secret_key = config["secret_key"]
33
+ req_key = config.get("model", "jimeng_t2i_v40")
34
+ out_dir = kwargs["out_dir"]
35
+ aspect_ratio = kwargs.get("aspect_ratio", "1:1")
36
+
37
+ vs = VisualService()
38
+ vs.set_ak(access_key)
39
+ vs.set_sk(secret_key)
40
+
41
+ # ═══ Step 1: 提交任务 ═══
42
+ body = {
43
+ "req_key": req_key,
44
+ "prompt": prompt,
45
+ "force_single": True, # 强制输出单图,确保智能体调用行为可预测
46
+ }
47
+
48
+ # 宽高映射
49
+ if aspect_ratio in self.RATIO_MAP:
50
+ w, h = self.RATIO_MAP[aspect_ratio]
51
+ body["width"] = w
52
+ body["height"] = h
53
+ else:
54
+ # 不传 width/height,让模型根据 prompt 自动判断
55
+ body["size"] = 2048 * 2048 # 2K 默认面积
56
+
57
+ print(f"[jimeng_image] 提交文生图任务 req_key={req_key} ratio={aspect_ratio}...", file=sys.stderr)
58
+ try:
59
+ data = vs.cv_sync2async_submit_task(body)
60
+ except Exception as e:
61
+ if hasattr(e, 'args') and len(e.args) > 0 and isinstance(e.args[0], bytes):
62
+ try:
63
+ err_json = json.loads(e.args[0].decode('utf-8'))
64
+ code = err_json.get("code", 0)
65
+ msg = err_json.get("message", "")
66
+ raise RuntimeError(f"即梦 API 错误: {msg} (code={code})")
67
+ except RuntimeError:
68
+ raise
69
+ except Exception:
70
+ pass
71
+ raise RuntimeError(f"即梦提交失败: {str(e)}")
72
+
73
+ if data.get("code") != 10000:
74
+ raise RuntimeError(f"即梦 API 错误: {data.get('message')} (code={data.get('code')})")
75
+
76
+ task_id = data["data"]["task_id"]
77
+ print(f"[jimeng_image] task_id={task_id}, 开始轮询...", file=sys.stderr)
78
+
79
+ # ═══ Step 2: 轮询状态 ═══
80
+ image_urls = None
81
+ binary_data = None
82
+ elapsed = 0
83
+ while elapsed < self.MAX_WAIT:
84
+ time.sleep(self.POLL_INTERVAL)
85
+ elapsed += self.POLL_INTERVAL
86
+
87
+ query_body = {
88
+ "req_key": req_key,
89
+ "task_id": task_id,
90
+ "req_json": json.dumps({"return_url": True}),
91
+ }
92
+ try:
93
+ qdata = vs.cv_sync2async_get_result(query_body)
94
+ except Exception as e:
95
+ if hasattr(e, 'args') and len(e.args) > 0 and isinstance(e.args[0], bytes):
96
+ try:
97
+ err_json = json.loads(e.args[0].decode('utf-8'))
98
+ code = err_json.get("code", 0)
99
+ if code in (50429, 50430, 50500, 50501):
100
+ print(f"[jimeng_image] 轮询 {elapsed}s: 可重试错误 code={code}", file=sys.stderr)
101
+ continue
102
+ raise RuntimeError(f"即梦查询失败: {err_json.get('message')} (code={code})")
103
+ except RuntimeError:
104
+ raise
105
+ except Exception:
106
+ pass
107
+ raise RuntimeError(f"即梦查询异常: {str(e)}")
108
+
109
+ if qdata.get("code") != 10000:
110
+ print(f"[jimeng_image] 轮询 {elapsed}s: code={qdata.get('code')} msg={qdata.get('message')}", file=sys.stderr)
111
+ if qdata.get("code") in (50429, 50430, 50500, 50501):
112
+ continue
113
+ raise RuntimeError(f"即梦状态获取失败: {qdata.get('message')} (code={qdata.get('code')})")
114
+
115
+ status = qdata.get("data", {}).get("status", "")
116
+ print(f"[jimeng_image] 轮询 {elapsed}s: status={status}", file=sys.stderr)
117
+
118
+ if status == "done":
119
+ image_urls = qdata["data"].get("image_urls", [])
120
+ binary_data = qdata["data"].get("binary_data_base64", [])
121
+ break
122
+ elif status in ("in_queue", "generating"):
123
+ continue
124
+ elif status in ("not_found", "expired"):
125
+ raise RuntimeError(f"即梦任务异常: status={status}")
126
+
127
+ if not image_urls and not binary_data:
128
+ raise RuntimeError(f"即梦图片生成超时 ({self.MAX_WAIT}s), task_id={task_id}")
129
+
130
+ print(f"[jimeng_image] 生成完成, 下载中...", file=sys.stderr)
131
+
132
+ # ═══ Step 3: 下载图片 ═══
133
+ import base64
134
+ saved = []
135
+
136
+ if image_urls:
137
+ for i, url in enumerate(image_urls):
138
+ fname = kwargs.get("output_path") or os.path.join(out_dir, f"image_{i}.png")
139
+ r = requests.get(url, timeout=60)
140
+ r.raise_for_status()
141
+ with open(fname, "wb") as f:
142
+ f.write(r.content)
143
+ saved.append(fname)
144
+ print(f"[jimeng_image] 已保存: {fname}", file=sys.stderr)
145
+ elif binary_data:
146
+ for i, b64 in enumerate(binary_data):
147
+ if not b64:
148
+ continue
149
+ fname = kwargs.get("output_path") or os.path.join(out_dir, f"image_{i}.png")
150
+ with open(fname, "wb") as f:
151
+ f.write(base64.b64decode(b64))
152
+ saved.append(fname)
153
+ print(f"[jimeng_image] 已保存 (base64): {fname}", file=sys.stderr)
154
+
155
+ if not saved:
156
+ raise RuntimeError("即梦未返回任何图片数据")
157
+
158
+ return saved
@@ -0,0 +1,115 @@
1
+ """即梦(Jimeng) video adapter — Volcengine SDK Implementation.
2
+ Supports text-to-video via CVSync2AsyncSubmitTask."""
3
+ import json
4
+ import os
5
+ import sys
6
+ import time
7
+
8
+ import requests
9
+ from volcengine.visual.VisualService import VisualService
10
+
11
+
12
+ class JimengVideoAdapter:
13
+ POLL_INTERVAL = 10
14
+ MAX_WAIT = 600
15
+
16
+ def generate(self, prompt, config, **kwargs):
17
+ access_key = config["access_key"]
18
+ secret_key = config["secret_key"]
19
+ req_key = config.get("model", "jimeng_t2v_v30")
20
+ out_dir = kwargs["out_dir"]
21
+
22
+ # duration → frames: 5s=121, 10s=241 (assuming default 5s max logic for now, standard expects 121 for 5s)
23
+ duration = int(kwargs.get("duration", 5))
24
+ frames = 24 * duration + 1
25
+
26
+ aspect_ratio = kwargs.get("aspect_ratio", "16:9")
27
+
28
+ # ═══ Step 1: 提交任务 ═══
29
+ vs = VisualService()
30
+ vs.set_ak(access_key)
31
+ vs.set_sk(secret_key)
32
+
33
+ body = {
34
+ "req_key": req_key,
35
+ "prompt": prompt,
36
+ "seed": -1,
37
+ "frames": frames,
38
+ "aspect_ratio": aspect_ratio,
39
+ }
40
+
41
+ print(f"[jimeng_video] 提交文生视频任务 req_key={req_key}...", file=sys.stderr)
42
+ try:
43
+ data = vs.cv_sync2async_submit_task(body)
44
+ except Exception as e:
45
+ # Handle SDK exceptions which wrap the raw response
46
+ if hasattr(e, 'args') and len(e.args) > 0 and isinstance(e.args[0], bytes):
47
+ try:
48
+ err_json = json.loads(e.args[0].decode('utf-8'))
49
+ raise RuntimeError(f"即梦 API 错误: {err_json}")
50
+ except Exception:
51
+ pass
52
+ raise RuntimeError(f"即梦提交失败: {str(e)}")
53
+
54
+ if data.get("code") != 10000:
55
+ raise RuntimeError(f"即梦 API 错误: {data.get('message')} (code={data.get('code')})")
56
+
57
+ task_id = data["data"]["task_id"]
58
+ print(f"[jimeng_video] task_id={task_id}, 开始轮询...", file=sys.stderr)
59
+
60
+ # ═══ Step 2: 轮询状态 ═══
61
+ video_url = None
62
+ elapsed = 0
63
+ while elapsed < self.MAX_WAIT:
64
+ time.sleep(self.POLL_INTERVAL)
65
+ elapsed += self.POLL_INTERVAL
66
+
67
+ query_body = {
68
+ "req_key": req_key,
69
+ "task_id": task_id,
70
+ }
71
+ try:
72
+ qdata = vs.cv_sync2async_get_result(query_body)
73
+ except Exception as e:
74
+ # Same exception handling for polling
75
+ if hasattr(e, 'args') and len(e.args) > 0 and isinstance(e.args[0], bytes):
76
+ try:
77
+ err_json = json.loads(e.args[0].decode('utf-8'))
78
+ if err_json.get("code") in (50500, 50501):
79
+ continue # Server error, can retry
80
+ raise RuntimeError(f"即梦查询失败: {err_json}")
81
+ except Exception:
82
+ pass
83
+ raise RuntimeError(f"即梦查询异常: {str(e)}")
84
+
85
+ if qdata.get("code") != 10000:
86
+ print(f"[jimeng_video] 轮询 {elapsed}s: code={qdata.get('code')} msg={qdata.get('message')}", file=sys.stderr)
87
+ if qdata.get("code") in (50500, 50501):
88
+ continue # 可重试的内部错误
89
+ raise RuntimeError(f"即梦状态获取失败: {qdata.get('message')} (code={qdata.get('code')})")
90
+
91
+ status = qdata.get("data", {}).get("status", "")
92
+ print(f"[jimeng_video] 轮询 {elapsed}s: status={status}", file=sys.stderr)
93
+
94
+ if status == "done":
95
+ video_url = qdata["data"].get("video_url")
96
+ break
97
+ elif status in ("in_queue", "generating"):
98
+ continue
99
+ elif status in ("not_found", "expired"):
100
+ raise RuntimeError(f"即梦任务异常停机: status={status}")
101
+
102
+ if not video_url:
103
+ raise RuntimeError(f"即梦视频生成超时 ({self.MAX_WAIT}s), task_id={task_id}")
104
+
105
+ print(f"[jimeng_video] 生成完成, 下载中...", file=sys.stderr)
106
+
107
+ # ═══ Step 3: 下载视频 ═══
108
+ fname = kwargs.get("output_path") or os.path.join(out_dir, "video.mp4")
109
+ r = requests.get(video_url, timeout=300)
110
+ r.raise_for_status()
111
+ with open(fname, "wb") as f:
112
+ f.write(r.content)
113
+
114
+ print(f"[jimeng_video] 已保存: {fname}", file=sys.stderr)
115
+ return [fname]
@@ -0,0 +1,115 @@
1
+ """MiniMax video adapter — POST /video_generation, poll status, download via file_id.
2
+ Supports both Text-to-Video and Image-to-Video modes."""
3
+ import json
4
+ import os
5
+ import sys
6
+ import time
7
+
8
+ import requests
9
+
10
+
11
+ class MiniMaxVideoAdapter:
12
+ # 轮询间隔和超时
13
+ POLL_INTERVAL = 10 # 每 10 秒查一次
14
+ MAX_WAIT = 600 # 最长等 10 分钟
15
+
16
+ def generate(self, prompt, config, **kwargs):
17
+ base_url = config["base_url"].rstrip("/")
18
+ api_key = config["api_key"]
19
+ model = config["model"]
20
+ out_dir = kwargs["out_dir"]
21
+
22
+ headers = {
23
+ "Authorization": f"Bearer {api_key}",
24
+ "Content-Type": "application/json",
25
+ }
26
+
27
+ # ═══ Step 1: 提交生成任务 ═══
28
+ payload = {
29
+ "model": model,
30
+ }
31
+ # prompt(图生视频时可选,文生视频时必填)
32
+ if prompt:
33
+ payload["prompt"] = prompt
34
+ # 图生视频模式:传入首帧图片
35
+ if kwargs.get("first_frame_image"):
36
+ payload["first_frame_image"] = kwargs["first_frame_image"]
37
+ # 可选参数
38
+ if kwargs.get("duration"):
39
+ payload["duration"] = int(kwargs["duration"])
40
+ if kwargs.get("resolution"):
41
+ payload["resolution"] = kwargs["resolution"]
42
+ if kwargs.get("prompt_optimizer") is not None:
43
+ payload["prompt_optimizer"] = kwargs["prompt_optimizer"]
44
+
45
+ mode = "图生视频" if "first_frame_image" in payload else "文生视频"
46
+ print(f"[minimax_video] 提交{mode}任务 model={model}...", file=sys.stderr)
47
+ resp = requests.post(
48
+ f"{base_url}/video_generation",
49
+ headers=headers,
50
+ json=payload,
51
+ timeout=60,
52
+ )
53
+ resp.raise_for_status()
54
+ data = resp.json()
55
+
56
+ # 检查业务错误
57
+ base_resp = data.get("base_resp", {})
58
+ if base_resp.get("status_code", 0) != 0:
59
+ raise RuntimeError(f"MiniMax error: {base_resp.get('status_msg')}")
60
+
61
+ task_id = data.get("task_id")
62
+ if not task_id:
63
+ raise RuntimeError(f"未返回 task_id: {data}")
64
+
65
+ print(f"[minimax_video] task_id={task_id}, 开始轮询...", file=sys.stderr)
66
+
67
+ # ═══ Step 2: 轮询任务状态 ═══
68
+ file_id = None
69
+ elapsed = 0
70
+ while elapsed < self.MAX_WAIT:
71
+ time.sleep(self.POLL_INTERVAL)
72
+ elapsed += self.POLL_INTERVAL
73
+
74
+ query_resp = requests.get(
75
+ f"{base_url}/query/video_generation",
76
+ headers={"Authorization": f"Bearer {api_key}"},
77
+ params={"task_id": task_id},
78
+ timeout=30,
79
+ )
80
+ query_resp.raise_for_status()
81
+ qdata = query_resp.json()
82
+ status = qdata.get("status", "")
83
+ print(f"[minimax_video] 轮询 {elapsed}s: status={status}", file=sys.stderr)
84
+
85
+ if status == "Success":
86
+ file_id = qdata.get("file_id")
87
+ break
88
+ elif status == "Fail":
89
+ err_msg = qdata.get("error_message", "未知错误")
90
+ raise RuntimeError(f"视频生成失败: {err_msg}")
91
+ # Processing / Queueing → 继续等
92
+
93
+ if not file_id:
94
+ raise RuntimeError(f"视频生成超时 ({self.MAX_WAIT}s), task_id={task_id}")
95
+
96
+ print(f"[minimax_video] 生成完成 file_id={file_id}, 下载中...", file=sys.stderr)
97
+
98
+ # ═══ Step 3: 获取下载 URL 并下载 ═══
99
+ file_resp = requests.get(
100
+ f"{base_url}/files/retrieve",
101
+ headers={"Authorization": f"Bearer {api_key}"},
102
+ params={"file_id": file_id},
103
+ timeout=30,
104
+ )
105
+ file_resp.raise_for_status()
106
+ download_url = file_resp.json()["file"]["download_url"]
107
+
108
+ fname = kwargs.get("output_path") or os.path.join(out_dir, "video.mp4")
109
+ r = requests.get(download_url, timeout=300)
110
+ r.raise_for_status()
111
+ with open(fname, "wb") as f:
112
+ f.write(r.content)
113
+
114
+ print(f"[minimax_video] 已保存: {fname}", file=sys.stderr)
115
+ return [fname]
@@ -0,0 +1,47 @@
1
+ #!/bin/bash
2
+ # 文生视频入口 (Text-to-Video)
3
+ # 主力模型: MiniMax-Hailuo-2.3 | 降级: 即梦 jimeng_t2v_v30
4
+ # 用法: ./video.sh --output-dir /abs/path --name 中文名 "描述" [--duration 6] [--resolution 768P] [--aspect-ratio 16:9]
5
+ set -euo pipefail
6
+
7
+ OUTPUT_DIR=""
8
+ NAME=""
9
+ PROMPT=""
10
+ DURATION=""
11
+ RESOLUTION=""
12
+ ASPECT_RATIO=""
13
+
14
+ while [ $# -gt 0 ]; do
15
+ case "$1" in
16
+ --output-dir) OUTPUT_DIR="$2"; shift 2 ;;
17
+ --name) NAME="$2"; shift 2 ;;
18
+ --duration) DURATION="$2"; shift 2 ;;
19
+ --resolution) RESOLUTION="$2"; shift 2 ;;
20
+ --aspect-ratio) ASPECT_RATIO="$2"; shift 2 ;;
21
+ *)
22
+ if [ -z "$PROMPT" ]; then
23
+ PROMPT="$1"
24
+ fi
25
+ shift
26
+ ;;
27
+ esac
28
+ done
29
+
30
+ if [ -z "$OUTPUT_DIR" ] || [ -z "$NAME" ] || [ -z "$PROMPT" ]; then
31
+ echo "用法: ./video.sh --output-dir <绝对路径> --name <中文名> \"视频描述\""
32
+ echo "示例: ./video.sh --output-dir /root/.openclaw/workspace --name 日落延时 \"a sunset timelapse\""
33
+ exit 1
34
+ fi
35
+
36
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
37
+
38
+ ARGS=()
39
+ ARGS+=("video")
40
+ ARGS+=("$PROMPT")
41
+ ARGS+=(--output-dir "$OUTPUT_DIR")
42
+ ARGS+=(--name "$NAME")
43
+ [ -n "$DURATION" ] && ARGS+=(--duration "$DURATION")
44
+ [ -n "$RESOLUTION" ] && ARGS+=(--resolution "$RESOLUTION")
45
+ [ -n "$ASPECT_RATIO" ] && ARGS+=(--aspect-ratio "$ASPECT_RATIO")
46
+
47
+ python3 "$SCRIPT_DIR/generate.py" "${ARGS[@]}"