auto-save 2026-05-13 20:51 (~7)
This commit is contained in:
@@ -2348,6 +2348,19 @@
|
||||
"message": "auto-save 2026-05-13 20:40 (~4)",
|
||||
"hash": "66f2495",
|
||||
"files_changed": 4
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T20:45:53+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-13 20:45 (~6)",
|
||||
"hash": "700fa24",
|
||||
"files_changed": 6
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T12:49:30Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 7 项未提交变更 · 最近提交:auto-save 2026-05-13 20:45 (~6)",
|
||||
"files_changed": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ def ensure_video_api_configured() -> None:
|
||||
if not VIDEO_API_BASE_URL and "ai.skg.com/ezlink" in base:
|
||||
raise HTTPException(
|
||||
503,
|
||||
"当前 SKG ezlink 网关未开通生视频 /videos 端点;请配置 VIDEO_API_BASE_URL/VIDEO_API_KEY 接 Seedance、Kling 或 Veo 3 的真实视频 API 后再生成。",
|
||||
"当前 SKG ezlink baseurl 已连通,但这把 key 未开通生视频 /videos 端点;需要 IT 给该分组开通视频端点,或改用真实 Seedance/Kling/Veo 3 视频 API。",
|
||||
)
|
||||
if not video_api_key():
|
||||
raise HTTPException(503, "VIDEO_API_KEY 或 LLM_API_KEY 未配置,无法调用生视频 API")
|
||||
|
||||
@@ -830,6 +830,30 @@ api/main.py
|
||||
<h2>变更记录</h2>
|
||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||
<div class="changelog">
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-13 · 视频 API 未开通时前置禁用按钮</h3>
|
||||
<span class="tag violet">StoryboardWorkbench</span>
|
||||
<span class="tag blue">Health</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>当前 SKG ezlink 未开 <code>/videos</code>,用户点生成后才看到失败 toast,容易误以为是某个分镜或模型选择错误。</p>
|
||||
<p><strong>改动:</strong>前端启动时读取 <code>/health</code> 的 <code>models.video_configured</code>;若为 false,分镜编排的生视频区域直接显示“视频 API 未开通”,并禁用提交按钮。</p>
|
||||
<p><strong>影响:</strong><code>web/lib/api.ts</code>、<code>web/app/page.tsx</code>、<code>web/components/storyboard-workbench.tsx</code>。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-13 · 生视频错误提示改为可读原因</h3>
|
||||
<span class="tag rose">VideoGenNode</span>
|
||||
<span class="tag blue">API</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>提交生视频失败时,前端把 <code>generateStoryboardVideo 503 {"detail": ...}</code> 原样展示,用户无法快速判断是配置、端点还是 UI 问题。</p>
|
||||
<p><strong>改动:</strong><code>generateStoryboardVideo</code> 解析后端 JSON 的 <code>detail</code> 后再抛错;后端 503 文案改为“SKG ezlink 已连通但当前 key 未开 /videos”;Video Gen 失败卡把 <code>/videos 404</code> 长错误压缩成一句可读原因。</p>
|
||||
<p><strong>影响:</strong><code>web/lib/api.ts</code>、<code>web/components/nodes/index.tsx</code>、<code>api/main.py</code>。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-13 · Video Gen 卡片增加复制和删除</h3>
|
||||
|
||||
@@ -16,9 +16,9 @@ import { ThemeToggle } from "@/components/theme-toggle"
|
||||
import { StoryboardBar } from "@/components/storyboard-bar"
|
||||
import { StoryboardWorkbench } from "@/components/storyboard-workbench"
|
||||
import {
|
||||
addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
|
||||
addManualFrame, analyzeJob, createJob, getHealth, getJob, uploadJob, deleteFrame, deleteGeneratedImage,
|
||||
deleteGeneratedVideo, generateStoryboardVideo,
|
||||
type Job, type ImageRef, type StoryboardScene,
|
||||
type BackendHealth, type Job, type ImageRef, type StoryboardScene,
|
||||
} from "@/lib/api"
|
||||
import { VideoLightbox } from "@/components/video-lightbox"
|
||||
|
||||
@@ -77,6 +77,7 @@ export default function Home() {
|
||||
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
|
||||
const [workbenchOpen, setWorkbenchOpen] = useState(false)
|
||||
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
|
||||
const [backendHealth, setBackendHealth] = useState<BackendHealth | null>(null)
|
||||
const flowRef = useRef<any>(null)
|
||||
|
||||
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
|
||||
@@ -228,6 +229,10 @@ export default function Home() {
|
||||
|
||||
const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => {
|
||||
if (!job) return
|
||||
if (backendHealth?.models?.video_configured === false) {
|
||||
toast.error("当前 SKG ezlink 未开通生视频端点,不能提交视频任务")
|
||||
return
|
||||
}
|
||||
const frame = job.frames.find((f) => f.index === frameIdx)
|
||||
if (!frame) return
|
||||
|
||||
@@ -265,7 +270,13 @@ export default function Home() {
|
||||
} catch (e) {
|
||||
toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}, [job, setJob])
|
||||
}, [job, setJob, backendHealth?.models?.video_configured])
|
||||
|
||||
useEffect(() => {
|
||||
getHealth()
|
||||
.then(setBackendHealth)
|
||||
.catch(() => setBackendHealth(null))
|
||||
}, [])
|
||||
|
||||
// URL ?job=xxx,yyy 自动恢复多个 job
|
||||
useEffect(() => {
|
||||
@@ -487,6 +498,7 @@ export default function Home() {
|
||||
onJobUpdate={setJob as any}
|
||||
clipboard={clipboard}
|
||||
focusedFrame={storyboardFrame}
|
||||
videoConfigured={backendHealth?.models?.video_configured}
|
||||
onGenerateVideo={handleQuickGenerateVideo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -960,6 +960,13 @@ export function VideoGenNode({ data, selected }: any) {
|
||||
if (m.includes("seedance")) return "Seedance"
|
||||
return model || "Video"
|
||||
}
|
||||
const readableVideoError = (error?: string) => {
|
||||
const e = error || "生成失败"
|
||||
if (e.includes("/videos") && e.includes("404")) {
|
||||
return "当前 SKG ezlink 未开通生视频 /videos 端点"
|
||||
}
|
||||
return e
|
||||
}
|
||||
return (
|
||||
<div className="relative" style={{ width: 280 }}>
|
||||
{videos.length > 0 && (
|
||||
@@ -1075,7 +1082,7 @@ export function VideoGenNode({ data, selected }: any) {
|
||||
<span className="shrink-0 font-mono text-white/55">{modelLabel(v.model)} · {v.status}</span>
|
||||
</div>
|
||||
<div className="line-clamp-3 text-[9.5px] leading-snug text-white/55">
|
||||
{v.status === "failed" ? (v.error || "生成失败") : v.prompt}
|
||||
{v.status === "failed" ? readableVideoError(v.error) : v.prompt}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ interface Props {
|
||||
onJobUpdate?: (j: Job) => void
|
||||
clipboard: ImageRef | null // 全局剪贴板(page.tsx 提供)
|
||||
focusedFrame: number | null
|
||||
videoConfigured?: boolean
|
||||
onGenerateVideo?: (frameIdx: number, scene: StoryboardScene, model: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
@@ -28,7 +29,7 @@ const VIDEO_MODELS = [
|
||||
{ value: "veo3", label: "Veo 3" },
|
||||
] as const
|
||||
|
||||
export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame, onGenerateVideo }: Props) {
|
||||
export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobUpdate, clipboard, focusedFrame, videoConfigured, onGenerateVideo }: Props) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
@@ -124,6 +125,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
|
||||
const hasVideoRefs = !!(form.subject_image || form.scene_image || form.product_image || form.action_image)
|
||||
const currentModelLabel = VIDEO_MODELS.find((m) => m.value === videoModel)?.label ?? "Seedance"
|
||||
const videoUnavailable = videoConfigured === false
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -302,8 +304,13 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{videoUnavailable && (
|
||||
<div className="mb-2 rounded-md border border-amber-300/25 bg-amber-500/10 px-2.5 py-2 text-[10.5px] leading-relaxed text-amber-100/80">
|
||||
当前 SKG ezlink 只连通基础网关,但未开通生视频 /videos 端点;这里先禁用提交,避免继续产生失败任务。
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
disabled={!hasVideoRefs || focusedIdx === null || generating}
|
||||
disabled={videoUnavailable || !hasVideoRefs || focusedIdx === null || generating}
|
||||
onClick={async () => {
|
||||
if (focusedIdx === null) return
|
||||
queueSave(form)
|
||||
@@ -315,13 +322,15 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
}
|
||||
}}
|
||||
className="w-full py-3 rounded-lg text-[13.5px] font-semibold inline-flex items-center justify-center gap-2 bg-gradient-to-r from-rose-500 to-violet-500 text-white border border-violet-300/40 shadow-lg shadow-violet-500/20 hover:from-rose-400 hover:to-violet-400 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={hasVideoRefs ? `调用 ${currentModelLabel} 生视频 API,结果进入 Video Gen 节点` : "先粘贴至少一张参考图"}
|
||||
title={videoUnavailable ? "当前视频 API 未开通" : hasVideoRefs ? `调用 ${currentModelLabel} 生视频 API,结果进入 Video Gen 节点` : "先粘贴至少一张参考图"}
|
||||
>
|
||||
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
|
||||
调用 {currentModelLabel} 生成视频
|
||||
{videoUnavailable ? "视频 API 未开通" : `调用 ${currentModelLabel} 生成视频`}
|
||||
</button>
|
||||
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
|
||||
用当前 4 图槽、改造目标和时长提交生视频 API;生成中的进度和完成后的 MP4 会显示在 Video Gen 节点。
|
||||
{videoUnavailable
|
||||
? "需要 IT 给当前 key 分组开通 /videos,或配置外部 VIDEO_API_BASE_URL/VIDEO_API_KEY 后再生成。"
|
||||
: "用当前 4 图槽、改造目标和时长提交生视频 API;生成中的进度和完成后的 MP4 会显示在 Video Gen 节点。"}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -146,12 +146,33 @@ export interface Job {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BackendHealth {
|
||||
ok: boolean
|
||||
llm_configured: boolean
|
||||
base_url: string
|
||||
models?: {
|
||||
asr?: string
|
||||
translate?: string
|
||||
rewrite?: string
|
||||
video?: string
|
||||
video_aliases?: Record<string, string>
|
||||
video_base_url?: string
|
||||
video_configured?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function apiAssetUrl(path?: string | null): string {
|
||||
if (!path) return ""
|
||||
if (/^https?:\/\//i.test(path)) return path
|
||||
return `${API_BASE}${path.startsWith("/") ? "" : "/"}${path}`
|
||||
}
|
||||
|
||||
export async function getHealth(): Promise<BackendHealth> {
|
||||
const res = await fetch(`${API_BASE}/health`)
|
||||
if (!res.ok) throw new Error(`health ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createJob(tkUrl: string): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs`, {
|
||||
method: "POST",
|
||||
@@ -372,7 +393,12 @@ export async function generateStoryboardVideo(
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`generateStoryboardVideo ${res.status} ${txt.slice(0, 300)}`)
|
||||
let detail = txt
|
||||
try {
|
||||
const parsed = JSON.parse(txt)
|
||||
detail = parsed?.detail || txt
|
||||
} catch {}
|
||||
throw new Error(detail || `generateStoryboardVideo ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user