130:    if(m.includes('SensitiveContent')||m.includes('sensitive'))return{reason:'参考图被检测为敏感内容',suggestion:'建议：1)点击"改为文生视频"重新生成 2)修改参考图提示词'};
134:    return{reason:m.substring(0,100),suggestion:'建议：尝试重新生成，或切换到文生视频模式'};
155:        h+=`<div class="col-md-6 mb-2"><div class="card"><div class="card-body p-2">${ch.image_path?``:'<div class="bg-light text-center py-3 text-muted small">暂无图片</div>'}<strong>${escapeHtml(ch.name)}</strong><p class="small text-muted mb-1">${escapeHtml(desc)}</p>${threeView?`📐 三视图提示词
${escapeHtml(threeView)}
`:''}<button class="btn btn-sm btn-outline-warning mt-1" onclick="regenerateCharImage(${i})"><i class="bi bi-pencil me-1"></i>修改提示词重生成</button></div></div></div>`;});h+='
';}
156:    if(result.scenes&&result.scenes.length){let ts=0;result.scenes.forEach(s=>ts+=(s.shots?.length||0));let missingShots=0;result.scenes.forEach(s=>{if(s.shots)s.shots.forEach(sh=>{if(!sh.image_path)missingShots++;});});h+=`<h6>🎬 分镜 (${result.scenes.length}场景/${ts}镜头)</h6>`;if(missingShots>0){h+=`<div class="mb-2"><button class="btn btn-outline-primary btn-sm" onclick="batchGenerateShotRefImages()"><i class="bi bi-images me-1"></i>批量生成镜头参考图 (${missingShots}个未生成)</button></div>`;}result.scenes.forEach((sc,idx)=>{let sceneBtnLabel=sc.image_path?'重生成场景图':'生成场景图';h+=`

🏞 ${escapeHtml(sc.location||sc.description?.substring(0,20)||'场景'+sc.scene_id)}
${sc.image_path?`<img src="${getImageUrl(sc.image_path)}" class="rounded img-zoomable me-1" style="max-height:60px" onclick="showImageModal(this.src)">`:''}<button class="btn btn-sm btn-outline-warning" onclick="regenerateSceneImage(${idx})">${sceneBtnLabel}</button>

`;if(sc.shots)sc.shots.forEach((sh,shIdx)=>{h+=`

${sh.image_path?`<img src="${getImageUrl(sh.image_path)}" class="rounded me-2 img-zoomable" style="max-height:120px;max-width:90px" onclick="showImageModal(this.src)">`:'
无图
'}
镜头${sh.shot_id} ${sh.shot_type||''} ${sh.duration_seconds||5}s
${escapeHtml(sh.description||'')}
${sh.dialogue?`<p class="small text-primary mb-1">💬 "${escapeHtml(sh.dialogue)}"</p>`:''}<button class="btn btn-sm btn-outline-warning mt-1" onclick="regenerateShotRefImage(${idx},${shIdx})">${sh.image_path?'重生成参考图':'生成参考图'}</button>

`;});h+='

';});}
168:                    h+=`<div class="mt-2"><video src="${vu}" controls class="video-inline" preload="metadata"></video></div>`;}if(shot.error||shot.status==='failed'){const a=analyzeVideoError(shot.error);if(a)h+=`<div class="alert alert-danger py-1 mt-2 small"><strong>❌ 原因:</strong> ${escapeHtml(a.reason)}<br><strong>💡 建议:</strong> ${escapeHtml(a.suggestion)}</div>`;else h+=`<div class="mt-1 text-danger small">❌ ${escapeHtml(shot.error||'未知')}</div>`;}h+=`
<button class="btn btn-sm btn-outline-warning" onclick="doRegenShot(${shot.shot_id})">重新生成</button> <button class="btn btn-sm btn-outline-info" onclick="doRegenShotPrompt(${shot.shot_id})">修改提示词重生成</button>${shot.error&&shot.mode==='img2vid'?` <button class="btn btn-sm btn-outline-success" onclick="doRegenShotText(${shot.shot_id})"><i class="bi bi-type me-1"></i>改为文生视频</button>`:''}

`;});}
177:if(r.improvement_suggestions&&r.improvement_suggestions.length){h+='
💡 改进建议 选择要执行的建议

';r.improvement_suggestions.forEach((s,i)=>h+=`
<input type="checkbox" class="form-check-input mt-1 improve-check" data-idx="${i}" checked id="improve_${i}">${escapeHtml(typeof s==='string'?s:JSON.stringify(s))}
`);h+='</ul>';h+=`
<textarea class="form-control form-control-sm" id="improveFeedback" rows="2" placeholder="额外修改说明(可选)"></textarea>
`;h+=`<button class="btn btn-success btn-sm" onclick="executeImprove()" id="btnExecuteImprove">🔄 执行选中建议并重新生成</button> `;}else if(r.suggestions&&r.suggestions.length){h+='<h6>💡 改进建议</h6><ul class="list-group mb-3">';r.suggestions.forEach(s=>h+=`
${escapeHtml(s)}
`);h+='
';}
187:async function batchGenerateSceneImages(){if(!currentTaskId||!window._wr)return;const scenes=window._wr.scenes;if(!scenes||!scenes.length){addLog('warning','没有场景数据');return;}const missing=scenes.filter(s=>!s.image_path);if(!missing.length){addLog('info','所有场景图已生成');return;}addLog('info',`开始批量生成${missing.length}个场景图...`);for(let i=0;i<scenes.length;i++){if(!scenes[i].image_path){addLog('info',`生成场景${i+1}图: ${scenes[i].location||''}...`);try{const r=await fetch(`${API_BASE}/tasks/${currentTaskId}/regenerate-image`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'scene',index:i})});const d=await r.json();if(d.success&&d.result){window._wr=d.result;displayWriteResult(d.result,true);addLog('success',`场景${i+1}图生成成功`);}else addLog('error',`场景${i+1}图失败: ${d.detail||d.message||'未知'}`);}catch(e){addLog('error',`场景${i+1}图失败: ${e.message}`);}}}addLog('success','批量场景图生成完成');}
188:async function regenerateShotRefImage(sceneIdx,shotIdx){if(!currentTaskId||!window._wr)return;const sc=window._wr.scenes[sceneIdx];if(!sc||!sc.shots||!sc.shots[shotIdx])return;const sh=sc.shots[shotIdx];const defaultPrompt=sh.image_prompt||sh.description||'';const p=await showPromptEditor('修改镜头'+sh.shot_id+'的参考图提示词',defaultPrompt);if(p===null)return;addLog('info','重新生成镜头'+sh.shot_id+'的参考图...');
192:try{const r=await fetch(`${API_BASE}/tasks/${currentTaskId}/regenerate-image`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'shot',scene_index:sceneIdx,shot_index:shotIdx,prompt:p})});const d=await r.json();if(d.success&&d.result){window._wr=d.result;displayWriteResult(d.result,true);addLog('success','镜头'+sh.shot_id+'参考图生成成功');}else{addLog('error','镜头'+sh.shot_id+'参考图失败: '+(d.detail||d.message||'未知'));displayWriteResult(window._wr,true);}}catch(e){addLog('error','镜头'+sh.shot_id+'参考图失败: '+e.message);displayWriteResult(window._wr,true);}}
196:for(let si=0;si<scenes.length;si++){const sc=scenes[si];if(!sc.shots)continue;for(let shi=0;shi<sc.shots.length;shi++){if(!sc.shots[shi].image_path){addLog('info',`生成镜头${sc.shots[shi].shot_id}参考图...`);try{const r=await fetch(`${API_BASE}/tasks/${currentTaskId}/regenerate-image`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'shot',scene_index:si,shot_index:shi})});const d=await r.json();if(d.success&&d.result){window._wr=d.result;displayWriteResult(d.result,true);done++;addLog('success',`镜头${sc.shots[shi].shot_id}参考图生成成功`);}else addLog('error',`镜头${sc.shots[shi].shot_id}失败: ${d.detail||d.message||'未知'}`);}catch(e){addLog('error',`镜头${sc.shots[shi].shot_id}失败: ${e.message}`);}}}}addLog('success',`批量镜头参考图生成完成: ${done}/${total}`);}
202:async function regenerateShotImage(sceneIdx,shotIdx){if(!currentTaskId||!window._wr)return;var sc=window._wr.scenes[sceneIdx];if(!sc)return;var sh=sc.shots[shotIdx];if(!sh)return;var p=await showPromptEditor('\u4fee\u6539\u955c\u5934'+sh.shot_id+'\u7684\u53c2\u8003\u56fe\u63d0\u793a\u8bcd',sh.image_prompt||'');if(p===null)return;addLog('info','\u91cd\u65b0\u751f\u6210\u955c\u5934'+sh.shot_id+'\u7684\u53c2\u8003\u56fe...');try{var r=await fetch(API_BASE+'/tasks/'+currentTaskId+'/regenerate-image',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'shot_ref',scene_index:sceneIdx,shot_index:shotIdx,prompt:p})});var d=await r.json();if(d.success){addLog('success','\u955c\u5934\u53c2\u8003\u56fe\u91cd\u751f\u6210\u6210\u529f');if(d.result){window._wr=d.result;displayWriteResult(d.result);}}else addLog('error','\u5931\u8d25: '+(d.detail||d.message||'\u672a\u77e5'));}catch(e){addLog('error','\u5931\u8d25: '+e.message);}}
209:async function regenerateCharImage(idx){if(!currentTaskId||!window._wr)return;const ch=window._wr.characters[idx];if(!ch)return;const existingPrompt=ch.appearance||ch.description||ch.personality||'';const defaultPrompt='角色设计图，'+ch.name+'，'+existingPrompt+'，全身像，清晰细节，白色背景，9:16竖屏，三视图(正面/侧面/背面)';const p=await showPromptEditor('修改角色"'+ch.name+'"的图片提示词',defaultPrompt);if(p===null)return;addLog('info','重新生成角色"'+ch.name+'"的图片...');
213:try{const r=await fetch(`${API_BASE}/tasks/${currentTaskId}/regenerate-image`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'character',index:idx,prompt:p})});const d=await r.json();if(d.success){addLog('success','角色图片重生成成功');if(d.result){window._wr=d.result;displayWriteResult(d.result);}}else addLog('error','失败: '+(d.detail||d.message||'未知'));}catch(e){addLog('error','失败: '+e.message);}}
214:async function regenerateSceneImage(idx){if(!currentTaskId||!window._wr)return;const sc=window._wr.scenes[idx];if(!sc)return;const loc=sc.location||sc.description?.substring(0,30)||'场景'+sc.scene_id;const p=await showPromptEditor('修改场景"'+loc+'"的背景图提示词','纯环境背景图，'+loc+'，电影感，氛围感，无人，9:16竖屏');if(p===null)return;addLog('info','重新生成场景"'+loc+'"的背景图...');
218:try{const r=await fetch(`${API_BASE}/tasks/${currentTaskId}/regenerate-image`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'scene',index:idx,prompt:p})});const d=await r.json();if(d.success){addLog('success','场景背景图重生成成功');if(d.result){window._wr=d.result;displayWriteResult(d.result);}}else addLog('error','失败: '+(d.detail||d.message||'未知'));}catch(e){addLog('error','失败: '+e.message);}}
221:async function doRegenShot(id,cp,mode){if(!currentTaskId)return;let u=`${API_BASE}/tasks/${currentTaskId}/shots/${id}/regenerate`;const ps=[];if(cp)ps.push('prompt='+encodeURIComponent(cp));if(mode)ps.push('mode='+encodeURIComponent(mode));if(ps.length)u+='?'+ps.join('&');addLog('info','重新生成镜头'+id+'...');try{const r=await fetch(u,{method:'POST'});const d=await r.json();if(d.success){addLog('success','镜头'+id+'重生成成功');if(d.result)displayProduceResult(d.result);document.getElementById('btnStitchVideos').style.display='inline-block';}else addLog('error','镜头'+id+'失败: '+d.message);}catch(e){addLog('error','失败: '+e.message);}}
222:async function doRegenShotPrompt(id){
228:    if(p!==null)doRegenShot(id,p||undefined);
230:function doRegenShotText(id){doRegenShot(id,undefined,'text2vid');}
236:async function executeStep(step,btnId,lt,ot){if(!currentTaskId){addLog('error','请先创建或选择任务');return;}const btn=document.getElementById(btnId);if(!btn)return;btn.disabled=true;btn.innerHTML=' '+lt+'...';updateStepStatus(step,'working');addLog('info','开始'+getStepName(step)+'...');try{let u=`${API_BASE}/tasks/${currentTaskId}/steps/${step}`;if(step==='produce'){const vm=currentTaskData?.video_mode||document.getElementById('taskVideoMode')?.value||'img2vid';u+='?video_mode='+vm;}const ctrl=new AbortController();const timer=setTimeout(()=>ctrl.abort(),600000);const r=await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},signal:ctrl.signal});clearTimeout(timer);if(!r.ok)throw new Error('HTTP '+r.status+': '+(await r.text()).substring(0,200));const d=await r.json();if(d.success){updateStepStatus(step,'completed',false,d.can_modify,d.can_regenerate);const nx={collect:'write',write:'produce',produce:'review'}[step];if(nx)updateStepStatus(nx,'pending',true);if(d.result){const fn={collect:displayCollectionResult,write:displayWriteResult,produce:displayProduceResult,review:displayReviewResult}[step];if(fn)fn(d.result);}await refreshTaskStatus();addLog('success',getStepName(step)+'完成！'+(d.message||''));}else throw new Error(d.message||'失败');}catch(e){updateStepStatus(step,'pending',true);if(e.name==='AbortError')addLog('error',getStepName(step)+'超时(10分钟)，服务可能仍在后台执行');else addLog('error',getStepName(step)+'失败: '+e.message);}finally{btn.disabled=false;btn.innerHTML=ot;}}

status exitCode durationMs aggregated cwd


**关键发现！** 前端`executeStep`函数调用的是：
```
POST /api/tasks/{id}/steps/produce?video_mode=img2vid
```

但后端日志返回 `405 Method Not Allowed`。让我检查后端路由：

exec

# 检查后端是否有 steps/{step} 的POST路由
grep -n "steps.*produce\|steps/{step}\|steps/{step_name}" /root/.openclaw/workspace/versions/user_driven_final_v3.0.1/manhua_studio_web_8000/api_server.py | head -10


547:@app.get("/api/tasks/{task_id}/steps/{step}")


**只有GET路由，没有POST路由！** 前端`executeStep`发的是POST请求，但后端只有GET。这就是405 Method Not Allowed的原因。

让我检查后端有哪些执行步骤的路由：

# 查看所有POST路由
grep -n "@app.post\|@app.put\|@app.patch" /root/.openclaw/workspace/versions/user_driven_final_v3.0.1/manhua_studio_web_8000/api_server.py


233:@app.post("/api/projects", response_model=Project)
294:@app.put("/api/projects/{project_id}", response_model=Project)
348:@app.post("/api/projects/{project_id}/workflow/{step_name}/start")
390:@app.post("/api/projects/{project_id}/workflow/{step_name}/complete")
496:@app.post("/api/tasks/create")
561:@app.patch("/api/tasks/{task_id}/config")
575:@app.post("/api/tasks/{task_id}/collect")
585:@app.post("/api/tasks/{task_id}/write")
595:@app.post("/api/tasks/{task_id}/produce")
605:@app.post("/api/tasks/{task_id}/review")
615:@app.post("/api/tasks/{task_id}/improve")
627:@app.post("/api/tasks/{task_id}/confirm-draft")
634:@app.post("/api/tasks/{task_id}/modify-scene")
645:@app.post("/api/tasks/{task_id}/modify-shot")
657:@app.post("/api/tasks/{task_id}/regenerate-image")
685:@app.post("/api/tasks/{task_id}/stitch")


**根因完全清楚了！** 前后端API路径不匹配：

| 前端调用 | 后端实际路由 | 结果 |
|---------|-------------|------|
| `POST /api/tasks/{id}/steps/collect` | `POST /api/tasks/{id}/collect` | 405 |
| `POST /api/tasks/{id}/steps/write` | `POST /api/tasks/{id}/write` | 405 |
| `POST /api/tasks/{id}/steps/produce` | `POST /api/tasks/{id}/produce` | 405 |
| `POST /api/tasks/{id}/steps/review` | `POST /api/tasks/{id}/review` | 405 |

前端用了`/steps/{step}`的通用路径，后端用的是`/collect`、`/write`、`/produce`、`/review`的独立路径。**这是同一个问题模式——前后端API契约不一致。**

最干净的修复方案：添加通用`POST /api/tasks/{id}/steps/{step}`路由，转发到对应的具体方法：

# 看现有的collect/write/produce/review路由实现
sed -n '575,615p' /root/.openclaw/workspace/versions/user_driven_final_v3.0.1/manhua_studio_web_8000/api_server.py


@app.post("/api/tasks/{task_id}/collect")
async def start_collection(task_id: str, request: Optional[Dict[str, Any]] = Body(default=None)):
    """开始采集任务"""
    user_input = None
    if request:
        user_input = {"modification": request.get("modification", ""), "preferences": request.get("preferences", {})}
    result = ud_task_system.execute_collect(task_id, user_input=user_input)
    return result

@app.post("/api/tasks/{task_id}/write")
async def start_writing(task_id: str, request: Optional[Dict[str, Any]] = Body(default=None)):
    """开始创作任务"""
    user_input = None
    if request:
        user_input = {"modification": request.get("modification", ""), "preferences": request.get("preferences", {})}
    result = ud_task_system.execute_write(task_id, user_input=user_input)
    return result

@app.post("/api/tasks/{task_id}/produce")
async def start_production(task_id: str, request: Optional[Dict[str, Any]] = Body(default=None)):
    """开始制作任务"""
    video_mode = None
    if request:
        video_mode = request.get("video_mode")
    result = ud_task_system.execute_produce(task_id, video_mode=video_mode)
    return result

@app.post("/api/tasks/{task_id}/review")
async def start_review(task_id: str, request: Optional[Dict[str, Any]] = Body(default=None)):
    """开始审核任务"""
    user_feedback = None
    if request:
        user_feedback = request.get("user_feedback")
    result = ud_task_system.execute_review(task_id, user_feedback=user_feedback)
    return result

@app.post("/api/tasks/{task_id}/improve")


# 还需要检查前端executeStep中produce的video_mode是怎么传的
# 从之前的grep结果看到：u+='?video_mode='+vm
# 所以前端是通过query parameter传video_mode，不是body
# 需要在通用路由中处理这个


(no output)


edit

@app.post("/api/tasks/{task_id}/steps/{step}")
async def execute_step(task_id: str, step: str, video_mode: Optional[str] = None, request: Optional[Dict[str, Any]] = Body(default=None)):
    """通用步骤执行路由 - 前端用 /steps/{step} 调用"""
    step_map = {
        "collect": ud_task_system.execute_collect,
        "write": ud_task_system.execute_write,
        "produce": ud_task_system.execute_produce,
        "review": ud_task_system.execute_review,
    }
    if step not in step_map:
        raise HTTPException(status_code=404, detail=f"未知步骤: {step}")

    fn = step_map[step]

    if step == "collect":
        user_input = None
        if request:
            user_input = {"modification": request.get("modification", ""), "preferences": request.get("preferences", {})}
        return fn(task_id, user_input=user_input)
    elif step == "write":
        user_input = None
        if request:
            user_input = {"modification": request.get("modification", ""), "preferences": request.get("preferences", {})}
        return fn(task_id, user_input=user_input)
    elif step == "produce":
        vm = video_mode
        if request and not vm:
            vm = request.get("video_mode")
        return fn(task_id, video_mode=vm)
    elif step == "review":
        user_feedback = None
        if request:
            user_feedback = request.get("user_feedback")
        return fn(task_id, user_feedback=user_feedback)

@app.post("/api/tasks/{task_id}/collect")
async def start_collection(task_id: str, request: Optional[Dict[str, Any]] = Body(default=None)):


Successfully replaced 1 block(s) in /root/.openclaw/workspace/versions/user_driven_final_v3.0.1/manhua_studio_web_8000/api_server.py.

     ...
 571     result = ud_task_system.delete_task(task_id)
 572     return result
 573
 574
+575 @app.post("/api/tasks/{task_id}/steps/{step}")
+576 async def execute_step(task_id: str, step: str, video_mode: Optional[str] = None, request: Optional[Dict[str, Any]] = Body(default=None)):
+577     """通用步骤执行路由 - 前端用 /steps/{step} 调用"""
+578     step_map = {
+579         "collect": ud_task_system.execute_collect,
+580         "write": ud_task_system.execute_write,
+581         "produce": ud_task_system.execute_produce,
+582         "review": ud_task_system.execute_review,
+583     }
+584     if step not in step_map:
+585         raise HTTPException(status_code=404, detail=f"未知步骤: {step}")
+586
+587     fn = step_map[step]
+588
+589     if step == "collect":
+590         user_input = None
+591         if request:
+592             user_input = {"modification": request.get("modification", ""), "preferences": request.get("preferences", {})}
+593         return fn(task_id, user_input=user_input)
+594     elif step == "write":
+595         user_input = None
+596         if request:
+597             user_input = {"modification": request.get("modification", ""), "preferences": request.get("preferences", {})}
+598         return fn(task_id, user_input=user_input)
+599     elif step == "produce":
+600         vm = video_mode
+601         if request and not vm:
+602             vm = request.get("video_mode")
+603         return fn(task_id, video_mode=vm)
+604     elif step == "review":
+605         user_feedback = None
+606         if request:
+607             user_feedback = request.get("user_feedback")
+608         return fn(task_id, user_feedback=user_feedback)
+609
+610
 575 @app.post("/api/tasks/{task_id}/collect")
 576 async def start_collection(task_id: str, request: Optional[Dict[str, Any]] = Body(default=None)):
 577     """开始采集任务"""
 578     user_input = None
     ...


现在还要检查前端中"重新生成视频"按钮调用的`doRegenShot`函数，它用的是另一个路径：

exec

# 检查doRegenShot调用的API路径
grep -n "doRegenShot\|shots.*regenerate" /root/.openclaw/workspace/versions/user_driven_final_v3.0.1/manhua_studio_web_8000/static/user_driven_coordinator.js | head -10


156:    if(result.scenes&&result.scenes.length){let ts=0;result.scenes.forEach(s=>ts+=(s.shots?.length||0));let missingShots=0;result.scenes.forEach(s=>{if(s.shots)s.shots.forEach(sh=>{if(!sh.image_path)missingShots++;});});h+=`<h6>🎬 分镜 (${result.scenes.length}场景/${ts}镜头)</h6>`;if(missingShots>0){h+=`<div class="mb-2"><button class="btn btn-outline-primary btn-sm" onclick="batchGenerateShotRefImages()"><i class="bi bi-images me-1"></i>批量生成镜头参考图 (${missingShots}个未生成)</button></div>`;}result.scenes.forEach((sc,idx)=>{let sceneBtnLabel=sc.image_path?'重生成场景图':'生成场景图';h+=`

🏞 ${escapeHtml(sc.location||sc.description?.substring(0,20)||'场景'+sc.scene_id)}
${sc.image_path?`<img src="${getImageUrl(sc.image_path)}" class="rounded img-zoomable me-1" style="max-height:60px" onclick="showImageModal(this.src)">`:''}<button class="btn btn-sm btn-outline-warning" onclick="regenerateSceneImage(${idx})">${sceneBtnLabel}</button>

`;if(sc.shots)sc.shots.forEach((sh,shIdx)=>{h+=`

${sh.image_path?`<img src="${getImageUrl(sh.image_path)}" class="rounded me-2 img-zoomable" style="max-height:120px;max-width:90px" onclick="showImageModal(this.src)">`:'
无图
'}
镜头${sh.shot_id} ${sh.shot_type||''} ${sh.duration_seconds||5}s
${escapeHtml(sh.description||'')}
${sh.dialogue?`<p class="small text-primary mb-1">💬 "${escapeHtml(sh.dialogue)}"</p>`:''}<button class="btn btn-sm btn-outline-warning mt-1" onclick="regenerateShotRefImage(${idx},${shIdx})">${sh.image_path?'重生成参考图':'生成参考图'}</button>

`;});h+='

';});}
168:                    h+=`<div class="mt-2"><video src="${vu}" controls class="video-inline" preload="metadata"></video></div>`;}if(shot.error||shot.status==='failed'){const a=analyzeVideoError(shot.error);if(a)h+=`<div class="alert alert-danger py-1 mt-2 small"><strong>❌ 原因:</strong> ${escapeHtml(a.reason)}<br><strong>💡 建议:</strong> ${escapeHtml(a.suggestion)}</div>`;else h+=`<div class="mt-1 text-danger small">❌ ${escapeHtml(shot.error||'未知')}</div>`;}h+=`
<button class="btn btn-sm btn-outline-warning" onclick="doRegenShot(${shot.shot_id})">重新生成</button> <button class="btn btn-sm btn-outline-info" onclick="doRegenShotPrompt(${shot.shot_id})">修改提示词重生成</button>${shot.error&&shot.mode==='img2vid'?` <button class="btn btn-sm btn-outline-success" onclick="doRegenShotText(${shot.shot_id})"><i class="bi bi-type me-1"></i>改为文生视频</button>`:''}

`;});}
196:for(let si=0;si<scenes.length;si++){const sc=scenes[si];if(!sc.shots)continue;for(let shi=0;shi<sc.shots.length;shi++){if(!sc.shots[shi].image_path){addLog('info',`生成镜头${sc.shots[shi].shot_id}参考图...`);try{const r=await fetch(`${API_BASE}/tasks/${currentTaskId}/regenerate-image`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'shot',scene_index:si,shot_index:shi})});const d=await r.json();if(d.success&&d.result){window._wr=d.result;displayWriteResult(d.result,true);done++;addLog('success',`镜头${sc.shots[shi].shot_id}参考图生成成功`);}else addLog('error',`镜头${sc.shots[shi].shot_id}失败: ${d.detail||d.message||'未知'}`);}catch(e){addLog('error',`镜头${sc.shots[shi].shot_id}失败: ${e.message}`);}}}}addLog('success',`批量镜头参考图生成完成: ${done}/${total}`);}
202:async function regenerateShotImage(sceneIdx,shotIdx){if(!currentTaskId||!window._wr)return;var sc=window._wr.scenes[sceneIdx];if(!sc)return;var sh=sc.shots[shotIdx];if(!sh)return;var p=await showPromptEditor('\u4fee\u6539\u955c\u5934'+sh.shot_id+'\u7684\u53c2\u8003\u56fe\u63d0\u793a\u8bcd',sh.image_prompt||'');if(p===null)return;addLog('info','\u91cd\u65b0\u751f\u6210\u955c\u5934'+sh.shot_id+'\u7684\u53c2\u8003\u56fe...');try{var r=await fetch(API_BASE+'/tasks/'+currentTaskId+'/regenerate-image',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'shot_ref',scene_index:sceneIdx,shot_index:shotIdx,prompt:p})});var d=await r.json();if(d.success){addLog('success','\u955c\u5934\u53c2\u8003\u56fe\u91cd\u751f\u6210\u6210\u529f');if(d.result){window._wr=d.result;displayWriteResult(d.result);}}else addLog('error','\u5931\u8d25: '+(d.detail||d.message||'\u672a\u77e5'));}catch(e){addLog('error','\u5931\u8d25: '+e.message);}}
221:async function doRegenShot(id,cp,mode){if(!currentTaskId)return;let u=`${API_BASE}/tasks/${currentTaskId}/shots/${id}/regenerate`;const ps=[];if(cp)ps.push('prompt='+encodeURIComponent(cp));if(mode)ps.push('mode='+encodeURIComponent(mode));if(ps.length)u+='?'+ps.join('&');addLog('info','重新生成镜头'+id+'...');try{const r=await fetch(u,{method:'POST'});const d=await r.json();if(d.success){addLog('success','镜头'+id+'重生成成功');if(d.result)displayProduceResult(d.result);document.getElementById('btnStitchVideos').style.display='inline-block';}else addLog('error','镜头'+id+'失败: '+d.message);}catch(e){addLog('error','失败: '+e.message);}}
222:async function doRegenShotPrompt(id){
228:    if(p!==null)doRegenShot(id,p||undefined);
230:function doRegenShotText(id){doRegenShot(id,undefined,'text2vid');}


**还有另一个缺失路由！** `doRegenShot`调用的是：
```
POST /api/tasks/{id}/shots/{shot_id}/regenerate
```

后端也没有这个路由。让我检查后端有哪些shot相关的路由：

grep -n "shots\|shot" /root/.openclaw/workspace/versions/user_driven_final_v3.0.1/manhua_studio_web_8000/api_server.py | head -10


681:@app.post("/api/tasks/{task_id}/modify-shot")
682:async def modify_shot(task_id: str, request: Dict[str, Any] = Body(...)):
684:    result = ud_task_system.modify_shot(
687:        shot_index=request.get("shot_index", 0),
696:    img_type = request.get("type", "shot_ref")
711:    else:  # shot_ref
712:        result = ud_task_system._generate_shot_ref_images(
713:            [{"scene_index": request.get("scene_index", 0), "shot_index": request.get("shot_index", 0),
714:              "image_prompt": request.get("prompt", ""), "shot_id": request.get("shot_id", 0)}],


# 看regenerate-image路由的完整实现
sed -n '657,720p' /root/.openclaw/workspace/versions/user_driven_final_v3.0.1/manhua_studio_web_8000/api_server.py


feedback=request.get("feedback", ""),
        auto_review=request.get("auto_review", True)
    )
    return result

@app.post("/api/tasks/{task_id}/confirm-draft")
async def confirm_draft(task_id: str):
    """确认草稿，生成场景图和参考图"""
    result = ud_task_system.confirm_write_draft(task_id)
    return result

@app.post("/api/tasks/{task_id}/modify-scene")
async def modify_scene(task_id: str, request: Dict[str, Any] = Body(...)):
    """修改场景"""
    result = ud_task_system.modify_scene(
        task_id,
        scene_index=request.get("scene_index", 0),
        modification=request.get("modification", "")
    )
    return result

@app.post("/api/tasks/{task_id}/modify-shot")
async def modify_shot(task_id: str, request: Dict[str, Any] = Body(...)):
    """修改分镜"""
    result = ud_task_system.modify_shot(
        task_id,
        scene_index=request.get("scene_index", 0),
        shot_index=request.get("shot_index", 0),
        modification=request.get("modification", "")
    )
    return result

@app.post("/api/tasks/{task_id}/regenerate-image")
async def regenerate_image(task_id: str, request: Dict[str, Any] = Body(...)):
    """重新生成图片(角色/场景/镜头参考图)"""
    img_type = request.get("type", "shot_ref")
    if img_type == "character":
        result = ud_task_system._generate_character_images(
            [request.get("prompt", "")],
            Path(ud_task_system.tasks_dir / task_id / "images"),
            style_prefix="",
            image_model=request.get("image_model")
        )
    elif img_type == "scene":
        result = ud_task_system._generate_scene_images(
            [{"description": request.get("prompt", ""), "scene_index": request.get("index", 0)}],
            Path(ud_task_system.tasks_dir / task_id / "images"),
            style_prefix="",
            image_model=request.get("image_model")
        )
    else:  # shot_ref
        result = ud_task_system._generate_shot_ref_images(
            [{"scene_index": request.get("scene_index", 0), "shot_index": request.get("shot_index", 0),
              "image_prompt": request.get("prompt", ""), "shot_id": request.get("shot_id", 0)}],
            Path(ud_task_system.tasks_dir / task_id / "images"),
            image_model=request.get("image_model")
        )
    return {"success": True, "result": result}


现在需要添加`POST /api/tasks/{id}/shots/{shot_id}/regenerate`路由。让我看看`user_driven_task_system.py`中是否有重新生成单个镜头视频的方法：

# 查找重新生成单个镜头视频的方法
grep -n "def.*regenerate.*shot\|def.*regen.*video\|def.*regenerate_video" /root/.openclaw/workspace/versions/user_driven_final_v3.0.1/manhua_studio_web_8000/user_driven_task_system.py | head -10


2162:    def regenerate_single_shot(self, task_id: str, shot_id: int, user_prompt: Optional[str] = None, mode: Optional[str] = None) -> Dict[str, Any]:


def regenerate_single_shot(self, task_id: str, shot_id: int, user_prompt: Optional[str] = None, mode: Optional[str] = None) -> Dict[str, Any]:
        """重新生成单个镜头的视频"""
        task_data = self._load_task(task_id)
        if not task_data:
            return {"success": False, "message": "任务不存在"}

        video_result = task_data.get("results", {}).get("video", {})
        if not video_result:
            return {"success": False, "message": "视频结果不存在,请先执行制作步骤"}

        shots = video_result.get("shots", [])
        shot_info = None
        for s in shots:
            if s.get("shot_id") == shot_id:
                shot_info = s
                break

        if not shot_info:
            return {"success": False, "message": f"镜头{shot_id}不存在"}

        task_dir = Path(task_data["files"]["task_dir"])
        videos_dir = task_dir / "videos"
        images_dir = task_dir / "images"
        videos_dir.mkdir(parents=True, exist_ok=True)

        # 获取原始镜头信息
        script_data = task_data.get("results", {}).get("script", {})
        scenes = script_data.get("scenes", []) if isinstance(script_data, dict) else []
        characters = script_data.get("characters", []) if isinstance(script_data, dict) else []

        original_shot = None
        parent_scene = None
        for scene in scenes:
            for sh in scene.get("shots", []):
                if sh.get("shot_id") == shot_id:
                    original_shot = sh
                    parent_scene = scene
                    break

        video_prompt = user_prompt or (original_shot.get("video_prompt", original_shot.get("description", "")) if original_shot else "")
        shot_mode = mode or shot_info.get("mode", "text2vid")
        ref_image = self._resolve_shot_ref_image(original_shot or {}, parent_scene or {}, characters) if original_shot else None

        try:
            if shot_mode.startswith("img2vid") and ref_image and Path(ref_image).exists():
                try:
                    result = generate_video_from_image(
                        image_path=ref_image, prompt=video_prompt, ratio="9:16",
                        model=task_data.get("video_model") if task_data.get("video_model") != "auto" else None,


model=task_data.get("video_model") if task_data.get("video_model") != "auto" else None,
                        duration=(original_shot or {}).get("duration_seconds", 5),
                        download_dir=str(videos_dir),
                    )
                except Exception as img_err:
                    if "SensitiveContent" in str(img_err) or "sensitive" in str(img_err).lower():
                        logger.warning(f"单镜头重新生成: 图生视频触发敏感内容检测,降级到文生视频")
                        shot_mode = "text2vid_fallback"
                        result = generate_video(
                            prompt=video_prompt, ratio="9:16",
                            model=task_data.get("video_model") if task_data.get("video_model") != "auto" else None,
                            duration=(original_shot or {}).get("duration_seconds", 5),
                            download_dir=str(videos_dir), timeout_wait=600,
                        )
                    else:
                        raise
            else:
                result = generate_video(
                    prompt=video_prompt, ratio="9:16",
                    model=task_data.get("video_model") if task_data.get("video_model") != "auto" else None,
                    duration=(original_shot or {}).get("duration_seconds", 5),
                    download_dir=str(videos_dir), timeout_wait=600,
                )

            new_shot = {
                "shot_id": shot_id,
                "status": "succeeded",
                "video_url": result.get("video_url", ""),
                "local_path": result.get("local_path", ""),
                "file_size": result.get("file_size", 0),
                "duration": result.get("duration"),
                "resolution": result.get("resolution"),
                "ref_image": ref_image,  # 保留参考图路径
                "mode": shot_mode,
            }
            # 添加字幕
            if result.get("local_path") and (original_shot or {}).get("dialogue"):
                try:
                    subtitled = self._add_subtitles_to_video(result["local_path"], original_shot)
                    if subtitled:
                        new_shot["local_path"] = subtitled