fix(video-canvas): persist edits and record live provider regression results
This commit is contained in:
parent
96b8e1662c
commit
5b0d75a738
@ -92,6 +92,7 @@ import {
|
||||
videoGenerationService,
|
||||
workflowAnalysisService,
|
||||
} from "@/services/canvas";
|
||||
import { useVideoCanvasPersistence } from "@/hooks/use-video-canvas-persistence";
|
||||
import type { CanvasInitialProject } from "@/types/canvas";
|
||||
import {
|
||||
getVideoCanvasFunctionBillingCopy,
|
||||
@ -4780,7 +4781,9 @@ export function LiblibVideoCanvasReplica({
|
||||
const [historyListDownloadPendingId, setHistoryListDownloadPendingId] = useState<string | null>(null);
|
||||
const [historyWatermarkRulesOpen, setHistoryWatermarkRulesOpen] = useState(false);
|
||||
const [isProjectLoading, setIsProjectLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const initialProjectRef = useRef(initialProject);
|
||||
initialProjectRef.current = initialProject;
|
||||
const [loadedProject, setLoadedProject] = useState<{ id: string; revision: string } | null>(null);
|
||||
const [, setShareEnabled] = useState(false);
|
||||
const [publishPublic, setPublishPublic] = useState(false);
|
||||
const [canvasCommand, setCanvasCommand] = useState<CanvasCommandState>(null);
|
||||
@ -4878,88 +4881,72 @@ export function LiblibVideoCanvasReplica({
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const hydrateProject = (project: {
|
||||
canvasData?: unknown;
|
||||
title?: string | null;
|
||||
}) => {
|
||||
const hydrateProject = (project: { canvasData?: unknown; title?: string | null }) => {
|
||||
const title = normalizeCanvasTitle(project.title);
|
||||
const restored = restoreNodes(project.canvasData);
|
||||
const data = project.canvasData as { viewport?: typeof DEFAULT_VIEWPORT; selectedNodeIds?: string[] } | undefined;
|
||||
setProjectTitle(title);
|
||||
setDraftTitle(title);
|
||||
setNodes(restoreNodes(project.canvasData));
|
||||
setNodes(restored);
|
||||
setEdges(restoreEdges(project.canvasData));
|
||||
const storedViewport = data?.viewport;
|
||||
setViewport(storedViewport && [storedViewport.x, storedViewport.y, storedViewport.zoom].every(Number.isFinite)
|
||||
? { x: storedViewport.x, y: storedViewport.y, zoom: clampCanvasZoom(storedViewport.zoom) }
|
||||
: DEFAULT_VIEWPORT);
|
||||
setSelectedNodeId(restored.find(node => data?.selectedNodeIds?.includes(node.id))?.id || null);
|
||||
};
|
||||
|
||||
async function loadProject() {
|
||||
setIsProjectLoading(true);
|
||||
const hasInitialProject = initialProject?.id === canvasId;
|
||||
setLoadedProject(null);
|
||||
try {
|
||||
if (hasInitialProject) {
|
||||
hydrateProject(initialProject);
|
||||
}
|
||||
|
||||
const result = await canvasService.getProject(canvasId);
|
||||
if (initialProjectRef.current?.id === canvasId) hydrateProject(initialProjectRef.current);
|
||||
const response = await fetch(`/api/canvas/${encodeURIComponent(canvasId)}`);
|
||||
let result = await response.json();
|
||||
if (!active) return;
|
||||
if (result.success && result.data) {
|
||||
hydrateProject(result.data);
|
||||
} else if (!hasInitialProject) {
|
||||
setProjectTitle("未命名");
|
||||
setDraftTitle("未命名");
|
||||
const seedResult = await canvasService.saveCanvasState(
|
||||
canvasId,
|
||||
{
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodeIds: [],
|
||||
viewport: DEFAULT_VIEWPORT,
|
||||
tool: "select",
|
||||
},
|
||||
"video"
|
||||
);
|
||||
if (!seedResult.success) {
|
||||
console.warn("Failed to seed blank video canvas", seedResult.error);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
// Only a confirmed missing project may be seeded; a failed load must never overwrite it.
|
||||
const created = await fetch(`/api/canvas/${encodeURIComponent(canvasId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "未命名", workspaceType: "video", canvasData: {
|
||||
nodes: [], edges: [], selectedNodeIds: [], viewport: DEFAULT_VIEWPORT, tool: "select",
|
||||
} }),
|
||||
});
|
||||
result = await created.json();
|
||||
if (!created.ok) throw new Error(result.error || "创建画布失败");
|
||||
} else if (!response.ok) {
|
||||
throw new Error(result.error || "加载画布失败");
|
||||
}
|
||||
if (!active) return;
|
||||
if (!result.success || !result.data?.updatedAt) throw new Error(result.error || "加载画布失败");
|
||||
hydrateProject(result.data);
|
||||
setLoadedProject({ id: canvasId, revision: String(result.data.updatedAt) });
|
||||
} catch (error) {
|
||||
console.warn("Failed to load video canvas project", error);
|
||||
if (active) toast.error(error instanceof Error ? error.message : "加载画布失败,请刷新重试");
|
||||
} finally {
|
||||
setIsProjectLoading(false);
|
||||
if (active) setIsProjectLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadProject();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [canvasId, initialProject]);
|
||||
return () => { active = false; };
|
||||
}, [canvasId]);
|
||||
|
||||
const saveCanvas = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
const result = await canvasService.saveCanvasState(
|
||||
canvasId,
|
||||
{
|
||||
nodes: serializeNodes(nodes) as any,
|
||||
edges: serializeEdges(edges) as any,
|
||||
selectedNodeIds: selectedNodeId ? [selectedNodeId] : [],
|
||||
viewport,
|
||||
tool: "select",
|
||||
},
|
||||
"video"
|
||||
);
|
||||
if (result.success) {
|
||||
toast.success("已保存");
|
||||
} else {
|
||||
toast.error(result.error || "保存失败");
|
||||
}
|
||||
setIsSaving(false);
|
||||
}, [canvasId, edges, nodes, selectedNodeId, viewport]);
|
||||
const canvasSnapshot = useMemo(() => JSON.stringify({
|
||||
title: projectTitle,
|
||||
canvasData: {
|
||||
nodes: serializeNodes(nodes), edges: serializeEdges(edges),
|
||||
selectedNodeIds: selectedNodeId ? [selectedNodeId] : [], viewport, tool: "select",
|
||||
},
|
||||
}), [nodes, edges, selectedNodeId, viewport, projectTitle]);
|
||||
const persistence = useVideoCanvasPersistence(canvasId, loadedProject, canvasSnapshot);
|
||||
|
||||
const commitTitle = useCallback(async () => {
|
||||
const commitTitle = useCallback(() => {
|
||||
const nextTitle = draftTitle.trim() || "未命名";
|
||||
setProjectTitle(nextTitle);
|
||||
setDraftTitle(nextTitle);
|
||||
setIsTitleEditing(false);
|
||||
await canvasService.updateProject(canvasId, { title: nextTitle }, "video");
|
||||
}, [canvasId, draftTitle]);
|
||||
}, [draftTitle]);
|
||||
|
||||
const addNodes = useCallback((nextNodes: CloneNode[], nextEdges: CloneEdge[]) => {
|
||||
setNodes((current) => [...current, ...nextNodes]);
|
||||
@ -10579,7 +10566,7 @@ export function LiblibVideoCanvasReplica({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-dvh w-full overflow-hidden bg-[#151515] text-white"
|
||||
className="fixed inset-0 h-dvh w-full overflow-clip bg-[#151515] text-white"
|
||||
style={{fontFamily:'-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif', "--spacing":"4px"} as CSSProperties}
|
||||
onContextMenu={openCanvasCommand}
|
||||
onDoubleClick={openCanvasCommand}
|
||||
@ -10958,6 +10945,14 @@ export function LiblibVideoCanvasReplica({
|
||||
membershipSummary={membershipSummary}
|
||||
modelOptions={canvasModelOptions}
|
||||
node={selectedNode}
|
||||
onDraftChange={(prompt, model) => {
|
||||
const targetId = selectedNode.id;
|
||||
setNodes(current => current.map(node => node.id === targetId ? {
|
||||
...node,
|
||||
draftValues: { ...node.draftValues, content: prompt ? [prompt] : [],
|
||||
params: { ...getNodeParams(node), prompt, model } },
|
||||
} : node));
|
||||
}}
|
||||
onSubmit={(prompt, model) => {
|
||||
setNodes((current) =>
|
||||
current.map((node) =>
|
||||
@ -11141,6 +11136,9 @@ export function LiblibVideoCanvasReplica({
|
||||
) : null}
|
||||
|
||||
<TopBar
|
||||
saveStatus={persistence.ready ? persistence.status : "unavailable"}
|
||||
saveMessage={persistence.message}
|
||||
onSave={persistence.save}
|
||||
draftTitle={draftTitle}
|
||||
isTitleEditing={isTitleEditing}
|
||||
onAccount={() => toggleTopPanel("account")}
|
||||
@ -11524,6 +11522,9 @@ export function LiblibVideoCanvasReplica({
|
||||
}
|
||||
|
||||
function TopBar({
|
||||
saveStatus,
|
||||
saveMessage,
|
||||
onSave,
|
||||
draftTitle,
|
||||
isTitleEditing,
|
||||
onAccount,
|
||||
@ -11536,6 +11537,9 @@ function TopBar({
|
||||
siteLogoDark,
|
||||
siteName,
|
||||
}: {
|
||||
saveStatus: "saved" | "pending" | "saving" | "error" | "unavailable";
|
||||
saveMessage?: string;
|
||||
onSave: () => void;
|
||||
draftTitle: string;
|
||||
isTitleEditing: boolean;
|
||||
onAccount: () => void;
|
||||
@ -11585,6 +11589,15 @@ function TopBar({
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
<button
|
||||
className={cn("rounded-lg px-2 py-1 text-xs", saveStatus === "error" ? "text-red-300" : "text-white/55")}
|
||||
disabled={saveStatus === "unavailable"}
|
||||
onClick={onSave}
|
||||
title={saveMessage || "保存画布(⌘/Ctrl+S)"}
|
||||
type="button"
|
||||
>
|
||||
{saveStatus === "saving" ? "保存中…" : saveStatus === "pending" ? "待保存" : saveStatus === "error" ? "保存失败,点击重试" : saveStatus === "unavailable" ? "画布未就绪" : "已保存"}
|
||||
</button>
|
||||
<button
|
||||
className="flex h-10 cursor-pointer items-center gap-2 rounded-xl border border-white/8 bg-[#202020]/95 px-3 text-[13px] text-white/78 shadow-[0_12px_30px_rgba(0,0,0,0.28)] transition hover:bg-[#2a2a2a] hover:text-white"
|
||||
onClick={onAccount}
|
||||
@ -13828,6 +13841,7 @@ function TextTargetModuleComposer({
|
||||
membershipSummary,
|
||||
modelOptions = [],
|
||||
node,
|
||||
onDraftChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
anchor: PresetControlAnchor;
|
||||
@ -13836,6 +13850,7 @@ function TextTargetModuleComposer({
|
||||
membershipSummary?: MembershipSummaryResponse;
|
||||
modelOptions?: CanvasModelOption[];
|
||||
node: CloneNode;
|
||||
onDraftChange: (prompt: string, model: string) => void;
|
||||
onSubmit: (prompt: string, model: string) => void;
|
||||
}) {
|
||||
const contentValues = node.draftValues?.content;
|
||||
@ -13892,13 +13907,17 @@ function TextTargetModuleComposer({
|
||||
modelOptions={modelOptions}
|
||||
onSelect={(option) => {
|
||||
setSelectedModel(option);
|
||||
onDraftChange(prompt, option);
|
||||
setMenu(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<textarea
|
||||
className="h-[158px] w-full resize-none bg-transparent text-[20px] leading-8 text-white/64 outline-none placeholder:text-white/38"
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setPrompt(event.target.value);
|
||||
onDraftChange(event.target.value, selectedModel);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
placeholder="写下你想讲的故事、场景或角色设定。例如:一个来自未来的机器人,在城市屋顶看星星。"
|
||||
value={prompt}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 视频画布模型匹配与逐项对照(2026-09-16)
|
||||
|
||||
参考:[LibTV 画布](https://www.liblib.tv/canvas)。以登录后的 1280×720 画布为界面对照基准。用户最新要求:模型接通即可,不继续真实生成测试。因此下表区分接口与参数核验、界面核验、实际生成,不能把未生成的功能标为产物验收通过,也不把局部对齐称为整站像素级复刻完成。
|
||||
参考:[LibTV 画布](https://www.liblib.tv/canvas)。以登录后的 1280×720 画布为界面对照基准。本记录对应此前“模型接通即可,不继续真实生成测试”的阶段;用户随后已要求恢复真实测试,最新结果见 [真实测试与参考站对照](video-canvas-real-test-2026-09-16.md)。因此下表区分接口与参数核验、界面核验、实际生成,不能把未生成的功能标为产物验收通过,也不把局部对齐称为整站像素级复刻完成。
|
||||
|
||||
## 模型结论
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
| 音频生视频 | 音频实际进入请求;筛选支持音频参考的适配器,无兼容模型明确拒绝 | 单测通过,线路未接通 |
|
||||
| 全能参考入口 | 由旧首帧专用编辑器改为统一视频节点编辑器,支持模式和模型切换 | 浏览器确认参考图与 8 秒参数 |
|
||||
| 脚本 / 角色场景提取 | 语言/视觉模型严格筛选,指定模型错误不静默替换;鉴权先于视频帧提取 | 类型检查与选择单测通过 |
|
||||
| 视频分析 / 提示词优化翻译 | 同上;替换生产配置中的失效文字模型引用 | 不发起真实分析 |
|
||||
| 视频分析 / 提示词优化翻译 | 分析模型配置已替换;后续实测发现视频提示词优化/翻译按钮仍仅提示待接入,不能视为功能已接通 | 见最新真实测试记录 |
|
||||
| 三视图、四宫格、九宫格、25 宫格 | 参考图编辑链路与独立提示词核对,模型覆盖传入生成请求 | 接线核验,不做效果验收 |
|
||||
| 角色脸部三视图、角色设定图、场景设定图、产品设定图 | 按当前参考站补入四项预设,支持管理员提示词及模型配置 | 代码与类型检查;不点击生成 |
|
||||
| 九宫格菜单 | 核对 11 项内容,采用 14px 文案、39.25px 行高与可滚动浮层,避免旧缩放导致菜单过小 | 参考 DOM 尺寸已记录 |
|
||||
|
||||
109
docs/video-canvas-real-test-2026-09-16.md
Normal file
109
docs/video-canvas-real-test-2026-09-16.md
Normal file
@ -0,0 +1,109 @@
|
||||
# 视频画布真实测试与 LibTV 对照(2026-09-16)
|
||||
|
||||
本轮按用户最新指令恢复真实调用。在独立本地工作树、127.0.0.1:3016、两个专用测试账号下运行真实接口,未启用 CANVAS_E2E_LOCAL 模拟模式。使用现有数据库配置及服务商线路;未改真实用户画布。参考站为 https://www.liblib.tv/canvas,浏览器统一为 1280×720。
|
||||
|
||||
**结论:有视频模型,但本轮尚不能验收视频生成成功。图片生成和本地视频处理通过;保存缺陷已本地修复;仍有未接入入口。不能把模型目录可见等同于可生成,也不能把当前页面称为完整像素级复刻。**
|
||||
|
||||
## 真实调用结果
|
||||
|
||||
| 测试 | 结果 | 证据 / 限制 |
|
||||
|---|---|---|
|
||||
| VIP98K 当前目录 + 当前 Key 授权模型 | 两份目录均 30 个模型,视频为 `veo-3.1-fast` | 当前可用目录只有这一条视频模型;不代表服务商历史或其他账号没有其他模型 |
|
||||
| 文生视频,8 秒 / 720P | 失败,32.185 秒 | 任务受理后上游结果 HTTP 409 / Video generation failed;本站返回 500 |
|
||||
| 首帧图生视频,同参数 | 失败,23.872 秒 | 上游结果 HTTP 409 / Video generation failed |
|
||||
| 首尾帧视频,2 张图 | 失败,10.148 秒 | 上游 HTTP 503 / media service temporarily unavailable |
|
||||
| 多参考视频,2 张图 | 服务端记录失败 | 上游 HTTP 503;测试客户端随后中断,没有完整客户端耗时记录,不算通过 |
|
||||
| 服务商原始示例直接调用对照 | 失败,约 9.3 秒 | POST 202,第一次轮询 202,第二次 409,明确 `task_failed`。证明本次失败可绕过本站复现,并非仅本站参数或轮询问题 |
|
||||
| 官方 Qwen3.8 Max 文本脚本 | 失败,8.940 秒 | Access denied / account in good standing / overdue-payment |
|
||||
| 官方 Qwen3.8 Max 图片理解 | 失败,9.390 秒 | 同上,官方账户状态阻塞;未擅自切回旧模型 |
|
||||
| GPT Image 2.5 参考图产品三视图 | **通过,70.108 秒** | 本站真实图片 API 返回新图片,红杯三视角合成图;输入为人工合成测试图。此项只覆盖同链路样本,不代表所有图片预设逐个验收 |
|
||||
| 原有 Veo 成片读取 | 通过 | 只读取之前成功的任务,不计作本轮生成成功;H264 1280×720、AAC、8 秒 |
|
||||
| 成片画面裁剪(实际处理函数) | **通过,479 ms** | 640×360、8 秒、H264 + AAC,新文件 897501 字节 |
|
||||
| 成片音轨提取(实际处理函数) | **通过,81 ms** | 8 秒 AAC,197583 字节 |
|
||||
| 成片去音轨(实际处理函数) | **通过,39 ms** | 1280×720、8 秒,只有视频流,1526056 字节 |
|
||||
| 裁剪 / 拆轨完整 HTTP API | 本地环境阻塞 | 本机代理把服务商域名解析为 198.18.1.165,安全下载器拒绝该保留网段。未放宽 SSRF 防护。上述处理通过不等于完整下载/上传 API 验收通过 |
|
||||
|
||||
直接对照任务 ID:`d3b6199d-df42-479b-bc5f-8c797f61659c`;请求 ID:`68a19660-123d-474b-aa48-cd4c6e51bed3`。可提交给 VIP98K 排查 `veo-3.1-fast` 线路。没有对失败是否扣费作假定。
|
||||
|
||||
## 与参考站逐项比较
|
||||
|
||||
| 项目 | LibTV 当前界面 | 本站本轮核验 | 结论 |
|
||||
|---|---|---|---|
|
||||
| 视频模型菜单 | 多个 Seedance、Kling、Wan、Hailuo、Vidu 等模型 | 只显示当前真正授权且兼容的 Veo,以及自动选择 | 匹配正确,模型覆盖范围不同 |
|
||||
| 参数 | 当前 Seedance 2.0 界面有多比例、480P/720P/1080P/4K、可调时长和音频开关 | Veo 为 16:9、720P/1080P、8 秒,菜单浏览器核验通过 | 不能复制不受当前线路支持的选项;参考菜单可见不代表每种组合都已验证 |
|
||||
| 文生 / 首帧 / 首尾帧 / 多参考 | 有相应模式,参考图数量影响可选项 | 四类请求均实测,当前上游失败 | 接口已接,产物未通过 |
|
||||
| 音频生视频 | 有入口和对应专用模型 | 当前无已启用可用音频驱动视频线路,部分入口仍待接入 | 未完成 |
|
||||
| 提示词优化 / 翻译 | 有操作入口 | 当前视频编辑器点击仅显示“后端待接入”;配置中的模型不能使入口自动可用 | 未完成,需接真实处理接口 |
|
||||
| 联网搜索 / 素材校验 / AutoLink | 三项开关可见 | 本站有前两项开关;未验证其真实执行,AutoLink 不齐全 | 不计作功能完成 |
|
||||
| 智能剪辑 / 逐帧拉片 / 导演台 | 智能剪辑、逐帧拉片 SD2.5、导演台入口 | 本站视频合成及导演台存在;逐帧拉片未完整对应 | 未完成全量对齐 |
|
||||
| 时段剪辑 | 参考有剪辑工具 | 本站记录时间范围后仍提示导出后端待接入 | 未完成;与已通过的“画面裁剪”不同 |
|
||||
| 视频超清 / 去字幕 / 人声背景音分离 | 有对应入口 | 无确认可用专用线路;普通拆音轨不能替代人声分离 | 未完成 |
|
||||
| 画布保存 | 参考可持久化工作区 | 本站原 saveCanvas 未被调用,刷新实际丢节点;本轮补自动保存、手动保存、⌘/Ctrl+S | 本地修复并通过浏览器刷新验证 |
|
||||
| 文本草稿、标题、缩放 | 应在继续编辑时保留 | 文本内容、未提交草稿、标题、50% 缩放均刷新恢复 | 本地通过 |
|
||||
| 保存冲突 | 本轮未修改参考站冲突状态 | 串行合并更新;409 停止覆盖并提示,网络错误保留脏数据供重试 | 4 项保存队列测试通过 |
|
||||
| 顶部栏稳定性 | 顶栏固定在视口 | 本站节点编辑导致原生容器滚动、顶栏移出视口;改为固定画布和 overflow-clip | 本地修复 |
|
||||
| 视觉一致性 | 深色画布、底部工具栏、节点浮层 | 布局相似,但品牌/顶部导航、底纹、图标、功能覆盖仍不同 | 局部对齐,非像素级完成 |
|
||||
|
||||
参考账号显示 20 积分,所选视频生成界面显示 135;未充值或购买,也未在参考站提交生成。因此没有“双站同提示词成片效果”的 A/B 结果。
|
||||
|
||||
## 本轮修复与验证
|
||||
|
||||
- 自动保存仅在项目成功加载后启用;只有确认 404 才初始化空画布,加载失败不能覆盖原记录。
|
||||
- 画布标题、节点、连线、选择和视口统一保存,恢复时不再只恢复节点。
|
||||
- 文本编辑器草稿和模型选择及时写入节点;同项目的服务器刷新不重新覆盖本地编辑。
|
||||
- 保存采用串行队列,保存期间的新修改合并为下一次写入;失败保留状态,409 不拿最新版本号强行覆盖。
|
||||
- 保存状态可见;未保存退出时提示;切至后台尝试保存。
|
||||
- VIP98K 日志增加受理任务 ID 和失败结果状态,便于区分排队、上游失败、前端问题。
|
||||
- 12 项模型/参数/媒体安全测试 + 4 项保存队列测试 + 36 项 VIP98K 相关回归,共 52 项通过;密钥检查通过。
|
||||
- 源码 TypeScript 检查通过。Next 开发目录生成的 routes.d.ts/validator.ts 有损坏片段,单独源码检查排除了临时构建目录;未把生成文件错误隐瞒为完整生产构建通过。
|
||||
- 浏览器真实数据库保存和刷新恢复通过;本轮修复尚未部署生产。
|
||||
|
||||
## 证据位置
|
||||
|
||||
本地 `.qa/video-retest/`:`result-*.json` 为真实 API 结果;`provider-control-*.json` 为直接上游对照;`generated-three-view.png` 为本次真实图片产物;`local-processing-results.json` 及 `local-{crop,silent}.mp4`、`local-audio.m4a` 为处理验证;`save-*-refresh.txt` / `draft-after-refresh.txt` 为保存恢复记录;`libtv-video-parameters.png` 与 `local-video-parameters.png` 为同视口截图。原始结果可能含临时签名媒体地址,未提交到 Git。
|
||||
|
||||
下一步阻塞明确:VIP98K 修复/提供可用视频线路;阿里云账户恢复推理权限;补齐未接入的工具,再逐项实测。未经真实产物验证的功能仍保持未验收状态。
|
||||
|
||||
## 配置功能逐项验收台账
|
||||
|
||||
“未单独实测”不能视为通过;已测试同链路不自动覆盖其他提示词或专用接口。
|
||||
|
||||
| 功能 | 配置类型 / 路由 | 本轮状态 |
|
||||
|---|---|---|
|
||||
| 角色脸部三视图 (`character-face-three-view`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 角色设定图 (`character-design-sheet`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 场景设定图 (`scene-design-sheet`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 产品设定图 (`product-design-sheet`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 剧情推演四宫格 (`story-beat-grid-4`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 角色三视图生成 (`character-three-view`) | image-edit / canvas-generate-image | 同图片 API 产品三视图样本通过;该角色预设未单独生成 |
|
||||
| 多机位九宫格 (`multi-view-nine-grid`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 25宫格连贯分镜 (`continuous-storyboard-grid-25`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 电影级光影校正 (`cinematic-lighting-correction`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 画面推演 - 3秒后 (`frame-prediction-plus-3s`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 画面推演 - 5秒前 (`frame-prediction-minus-5s`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 宫格切分 (`grid-split`) | utility / local | 未单独实测,不能标为效果验收通过 |
|
||||
| 打光 (`lighting`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 高清 (`hd-upscale`) | utility / enhance | 未单独实测,不能标为效果验收通过 |
|
||||
| 720 全景 (`panorama-720`) | image-edit / extend | 未单独实测,不能标为效果验收通过 |
|
||||
| 扩图 (`image-outpaint`) | utility / extend | 未单独实测,不能标为效果验收通过 |
|
||||
| 重绘 (`image-repaint`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 擦除 (`image-erase`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 抠图 (`remove-bg`) | utility / remove-bg | 未单独实测,不能标为效果验收通过 |
|
||||
| 裁剪 (`image-crop`) | utility / local | 未单独实测,不能标为效果验收通过 |
|
||||
| 多角度编辑器 (`angle-edit`) | image-edit / angle-edit | 未单独实测,不能标为效果验收通过 |
|
||||
| 故事脚本生成 (`story-script`) | multimodal / canvas-workflow-analyze | 真实调用失败:官方 Qwen3.8 账户状态 |
|
||||
| 视频解析 (`video-analysis`) | multimodal / canvas-workflow-analyze | 分析默认模型被账户状态拦截;视频逐帧入口未单独实测 |
|
||||
| 图片反推提示词 (`image-to-prompt`) | multimodal / canvas-workflow-analyze | 同视觉理解链路被账户状态拦截;该预设未单独实测 |
|
||||
| 视频元素标记 (`element-mark`) | multimodal / pick-object | 未单独实测,不能标为效果验收通过 |
|
||||
| 文生视频 (`text-to-video`) | video / canvas-generate-video | 真实调用失败:上游 409 |
|
||||
| 首帧图生视频 (`first-frame-video`) | video / canvas-generate-video | 真实调用失败:上游 409 |
|
||||
| 首尾帧生成视频 (`first-last-frame-video`) | video / canvas-generate-video | 真实调用失败:上游 503 |
|
||||
| 音频生视频 (`audio-to-video`) | video / canvas-generate-video | 无确认可用线路,入口仍有待接入分支 |
|
||||
| 导演台 AI 识图导入 (`director-ai-import`) | multimodal / director-ai-import | 未单独实测,不能标为效果验收通过 |
|
||||
| 导演台 AI 全景图 (`director-panorama-ai`) | image-edit / canvas-generate-image | 未单独实测,不能标为效果验收通过 |
|
||||
| 智能去字幕 (`video-subtitle-erase`) | utility / local | 无确认可用专用处理线路,不算完成 |
|
||||
| 视频提示词优化 (`video-prompt-optimize`) | utility / canvas-prompt-tools | 视频节点按钮未接后端 |
|
||||
| 视频音频分离 (`video-audio-separation`) | audio / local | 实际处理函数:提取音轨/静音通过;完整 API 受本机 DNS 阻塞;人声/背景音分离未接通 |
|
||||
| 视频提示词翻译 (`video-prompt-translate`) | utility / canvas-prompt-tools | 视频节点按钮未接后端 |
|
||||
|
||||
测试收尾:两个专用账号均已停用并清除活跃会话,测试环境凭据已删除,本地开发服务及数据库隧道已关闭。参考站本轮新增空视频节点已删除,原有两张图片节点保留,缩放恢复 100%。
|
||||
81
hooks/use-video-canvas-persistence.ts
Normal file
81
hooks/use-video-canvas-persistence.ts
Normal file
@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { CanvasRevisionConflict, VideoCanvasSaveQueue, type VideoCanvasSaveStatus } from "@/lib/video-canvas/save-queue";
|
||||
|
||||
export function useVideoCanvasPersistence(
|
||||
projectId: string,
|
||||
loaded: { id: string; revision: string } | null,
|
||||
snapshot: string,
|
||||
) {
|
||||
const queueRef = useRef<VideoCanvasSaveQueue | null>(null);
|
||||
const latestSnapshot = useRef(snapshot);
|
||||
latestSnapshot.current = snapshot;
|
||||
const [state, setState] = useState<{ status: VideoCanvasSaveStatus; message?: string }>({ status: "saved" });
|
||||
const ready = loaded?.id === projectId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !loaded) return;
|
||||
let active = true;
|
||||
const queue = new VideoCanvasSaveQueue(latestSnapshot.current, loaded.revision, async (value, revision) => {
|
||||
const response = await fetch(`/api/canvas/${encodeURIComponent(projectId)}`, {
|
||||
method: "PUT",
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...JSON.parse(value), workspaceType: "video", baseCanvasUpdatedAt: revision }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.status === 409) {
|
||||
throw new CanvasRevisionConflict("此画布已在其他页面更新。请先保留当前内容,再刷新加载最新版本。");
|
||||
}
|
||||
if (!response.ok || !result.success || !result.data?.updatedAt) {
|
||||
throw new Error(result.error || "保存失败,请点击重试");
|
||||
}
|
||||
return String(result.data.updatedAt);
|
||||
}, (status, message) => { if (active) setState({ status, message }); });
|
||||
queueRef.current = queue;
|
||||
setState({ status: "saved" });
|
||||
// Fixed cadence also saves while generation progress is continuously changing.
|
||||
const interval = window.setInterval(() => {
|
||||
if (queue.dirty && !queue.failed) void queue.flush();
|
||||
}, 1500);
|
||||
const onHidden = () => { if (document.visibilityState === "hidden") void queue.flush(); };
|
||||
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!queue.dirty) return;
|
||||
void queue.flush();
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
document.addEventListener("visibilitychange", onHidden);
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", onHidden);
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
void queue.flush();
|
||||
if (queueRef.current === queue) queueRef.current = null;
|
||||
};
|
||||
}, [projectId, ready, loaded]);
|
||||
|
||||
useEffect(() => { if (ready) queueRef.current?.update(snapshot); }, [snapshot, ready]);
|
||||
|
||||
const save = useCallback(() => {
|
||||
if (!ready) return;
|
||||
queueRef.current?.update(latestSnapshot.current);
|
||||
void queueRef.current?.flush();
|
||||
}, [ready]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
save();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [save]);
|
||||
|
||||
return { ...state, save, ready };
|
||||
}
|
||||
@ -415,7 +415,14 @@ async function fetchTaskResult(params: {
|
||||
});
|
||||
const payload = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status})`);
|
||||
logger.error("task result request failed", {
|
||||
taskId: params.taskId,
|
||||
httpStatus: response.status,
|
||||
status: extractStatus(payload),
|
||||
providerMessage: extractErrorMessage(payload),
|
||||
...extractPayloadShape(payload),
|
||||
});
|
||||
throw new Error(`${extractErrorMessage(payload)} (HTTP ${response.status}, task_id: ${params.taskId})`);
|
||||
}
|
||||
|
||||
const status = extractStatus(payload);
|
||||
@ -1124,6 +1131,7 @@ async function submitTask(params: {
|
||||
attempt,
|
||||
status: response.status,
|
||||
providerRequestId: response.headers.get("x-request-id"),
|
||||
taskId: response.ok ? extractTaskId(payload) : undefined,
|
||||
attemptDurationMs: Date.now() - attemptStartedAt,
|
||||
totalDurationMs: Date.now() - submissionStartedAt,
|
||||
});
|
||||
|
||||
60
lib/video-canvas/save-queue.ts
Normal file
60
lib/video-canvas/save-queue.ts
Normal file
@ -0,0 +1,60 @@
|
||||
export type VideoCanvasSaveStatus = "saved" | "pending" | "saving" | "error";
|
||||
|
||||
/** Keep edits made during a request; never retry a stale revision as an overwrite. */
|
||||
export class VideoCanvasSaveQueue {
|
||||
private latest: string;
|
||||
private saved: string;
|
||||
private running: Promise<void> | null = null;
|
||||
private blocked = false;
|
||||
private status: VideoCanvasSaveStatus = "saved";
|
||||
|
||||
constructor(
|
||||
baseline: string,
|
||||
private revision: string,
|
||||
private readonly write: (snapshot: string, revision: string) => Promise<string>,
|
||||
private readonly changed: (status: VideoCanvasSaveStatus, message?: string) => void,
|
||||
) {
|
||||
this.latest = baseline;
|
||||
this.saved = baseline;
|
||||
}
|
||||
|
||||
get failed() { return this.status === "error"; }
|
||||
|
||||
get dirty() { return this.latest !== this.saved || this.running !== null; }
|
||||
|
||||
update(snapshot: string) {
|
||||
this.latest = snapshot;
|
||||
if (!this.running && this.status !== "error") {
|
||||
this.setStatus(this.dirty ? "pending" : "saved");
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(status: VideoCanvasSaveStatus, message?: string) {
|
||||
this.status = status;
|
||||
this.changed(status, message);
|
||||
}
|
||||
|
||||
flush(): Promise<void> {
|
||||
if (this.running) return this.running;
|
||||
if (this.blocked || !this.dirty) return Promise.resolve();
|
||||
this.running = this.drain().finally(() => { this.running = null; });
|
||||
return this.running;
|
||||
}
|
||||
|
||||
private async drain() {
|
||||
this.setStatus("saving");
|
||||
try {
|
||||
while (this.latest !== this.saved) {
|
||||
const snapshot = this.latest;
|
||||
this.revision = await this.write(snapshot, this.revision);
|
||||
this.saved = snapshot;
|
||||
}
|
||||
this.setStatus("saved");
|
||||
} catch (error) {
|
||||
this.blocked = error instanceof CanvasRevisionConflict;
|
||||
this.setStatus("error", error instanceof Error ? error.message : "保存失败,请重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CanvasRevisionConflict extends Error {}
|
||||
@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: [
|
||||
/routes\/video-canvas-save-queue\.test\.ts/,
|
||||
/routes\/video-canvas-model-matching\.test\.ts/,
|
||||
/routes\/canvas-chat-helpers\.test\.ts/,
|
||||
/routes\/agent-main-ai-template-entry\.test\.ts/,
|
||||
|
||||
59
tests/routes/video-canvas-save-queue.test.ts
Normal file
59
tests/routes/video-canvas-save-queue.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { CanvasRevisionConflict, VideoCanvasSaveQueue } from "@/lib/video-canvas/save-queue";
|
||||
|
||||
test("coalesces edits made during a save and uses the returned revision", async () => {
|
||||
let release!: (value: string) => void;
|
||||
const calls: string[][] = [];
|
||||
const queue = new VideoCanvasSaveQueue("initial", "r0", async (value, revision) => {
|
||||
calls.push([value, revision]);
|
||||
return calls.length === 1 ? new Promise<string>(resolve => { release = resolve; }) : "r2";
|
||||
}, () => {});
|
||||
queue.update("first");
|
||||
const pending = queue.flush();
|
||||
queue.update("second");
|
||||
queue.update("latest");
|
||||
expect(queue.flush()).toBe(pending);
|
||||
release("r1");
|
||||
await pending;
|
||||
expect(calls).toEqual([["first", "r0"], ["latest", "r1"]]);
|
||||
expect(queue.dirty).toBe(false);
|
||||
});
|
||||
|
||||
test("failed save retains edits and retries only the latest snapshot", async () => {
|
||||
const calls: string[][] = [];
|
||||
const queue = new VideoCanvasSaveQueue("initial", "r0", async (value, revision) => {
|
||||
calls.push([value, revision]);
|
||||
if (calls.length === 1) throw new Error("offline");
|
||||
return "r1";
|
||||
}, () => {});
|
||||
queue.update("first");
|
||||
await queue.flush();
|
||||
expect(queue.dirty).toBe(true);
|
||||
expect(queue.failed).toBe(true);
|
||||
queue.update("latest");
|
||||
await queue.flush();
|
||||
expect(calls).toEqual([["first", "r0"], ["latest", "r0"]]);
|
||||
expect(queue.dirty).toBe(false);
|
||||
});
|
||||
|
||||
test("revision conflict cannot be retried as an overwrite", async () => {
|
||||
let calls = 0;
|
||||
const queue = new VideoCanvasSaveQueue("initial", "r0", async () => {
|
||||
calls++;
|
||||
throw new CanvasRevisionConflict("changed elsewhere");
|
||||
}, () => {});
|
||||
queue.update("first");
|
||||
await queue.flush();
|
||||
queue.update("latest");
|
||||
await queue.flush();
|
||||
expect(calls).toBe(1);
|
||||
expect(queue.dirty).toBe(true);
|
||||
});
|
||||
|
||||
test("unchanged canvas does not write", async () => {
|
||||
let calls = 0;
|
||||
const queue = new VideoCanvasSaveQueue("initial", "r0", async () => { calls++; return "r1"; }, () => {});
|
||||
queue.update("initial");
|
||||
await queue.flush();
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user