feat(canvas): support filling video first/last frame from selected canvas images
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-18 20:12:55 +08:00
parent cc26d2eb7f
commit d1518a7606

View File

@ -756,6 +756,25 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
const selectedImageNodes = nodes.filter(
n => selectedNodeIds.includes(n.id) && n.type === 'image' && (n.data as any)?.src
);
const getCanvasNodeAttachment = useCallback(
(node: any, fallbackName: string): PickedObjectAttachment | null => {
const src = (node?.data as any)?.src;
if (!src || typeof src !== "string") return null;
const nodeTitle =
String((node?.data as any)?.title || "").trim() ||
String((node?.data as any)?.prompt || "").trim() ||
fallbackName;
return {
url: src,
name: nodeTitle,
contentType: "image/jpeg",
};
},
[]
);
// 🆕 辅助函数:在 contenteditable 中插入图片标签
const insertImageTagInEditor = useCallback((url: string, name: string, index: number) => {
@ -945,36 +964,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
}
}, [canvasId, addNode, insertImageTagInEditor]);
// 🆕 监听 pendingChatAttachments,当用户点击"添加到对话"按钮时添加标签
useEffect(() => {
if (pendingChatAttachments.length > 0) {
// 获取当前附件列表
const currentAttachments = [...attachments];
const existingUrls = new Set(currentAttachments.map(a => a.url));
// 过滤出需要添加的图片(不在当前附件列表中的)
const toAdd = pendingChatAttachments.filter(a => !existingUrls.has(a.url));
if (toAdd.length > 0) {
// 插入图片标签到编辑器
toAdd.forEach((att, i) => {
insertImageTagInEditor(att.url, att.name, currentAttachments.length + i);
});
// 更新附件列表
setAttachments([...currentAttachments, ...toAdd]);
// 聚焦到输入框
setTimeout(() => {
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
}
// 清空待添加列表
clearPendingChatAttachments();
}
}, [pendingChatAttachments, attachments, insertImageTagInEditor, clearPendingChatAttachments]);
// 🆕 监听 pendingPickedObjects,当用户确认拾取物品时添加标签
useEffect(() => {
if (pendingPickedObjects.length > 0) {
@ -1189,6 +1178,82 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
return [] as string[];
}, [isVideoModel, videoParams]);
// 监听 pendingChatAttachments:视频模式时填充首尾帧槽位,非视频模式插入对话标签
useEffect(() => {
if (pendingChatAttachments.length === 0) return;
if (isVideoModel && videoFrameSlots.length > 0) {
const slotCount = videoFrameSlots.length;
const currentSlots: Array<PickedObjectAttachment | null> = Array.from(
{ length: slotCount },
(_, index) => attachments[index] ?? null
);
const existingUrls = new Set(currentSlots.filter(Boolean).map((item) => item!.url));
const toAdd = pendingChatAttachments
.filter((item) => item?.url && !existingUrls.has(item.url))
.map((item) => ({
url: item.url,
name: item.name || "参考图",
contentType: item.contentType || "image/jpeg",
})) as PickedObjectAttachment[];
let cursor = 0;
let inserted = 0;
for (let index = 0; index < slotCount && cursor < toAdd.length; index += 1) {
if (currentSlots[index]) continue;
currentSlots[index] = toAdd[cursor]!;
cursor += 1;
inserted += 1;
}
if (inserted > 0) {
const next = currentSlots
.slice(0, slotCount)
.filter((item): item is PickedObjectAttachment => Boolean(item));
setAttachments(next);
toast.success(slotCount === 1 ? "已添加到视频首帧" : "已添加到首尾帧槽位");
} else if (toAdd.length > 0) {
toast.info("首尾帧槽位已满,请先替换或移除");
}
if (toAdd.length - inserted > 0) {
toast.info(`还有 ${toAdd.length - inserted} 张未添加,可继续替换首尾帧`);
}
setTimeout(() => {
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
clearPendingChatAttachments();
return;
}
const currentAttachments = [...attachments];
const existingUrls = new Set(currentAttachments.map((item) => item.url));
const toAdd = pendingChatAttachments.filter((item) => !existingUrls.has(item.url));
if (toAdd.length > 0) {
toAdd.forEach((item, index) => {
insertImageTagInEditor(item.url, item.name, currentAttachments.length + index);
});
setAttachments([...currentAttachments, ...toAdd]);
setTimeout(() => {
(textareaRef.current as HTMLDivElement | null)?.focus();
}, 0);
}
clearPendingChatAttachments();
}, [
pendingChatAttachments,
isVideoModel,
videoFrameSlots.length,
attachments,
insertImageTagInEditor,
clearPendingChatAttachments,
]);
const firstImageModelId = useMemo(
() =>
allModels.find(
@ -1911,6 +1976,20 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
[videoFrameSlots.length]
);
const fillVideoSlotFromCanvasSelection = useCallback(
(slotIndex: number) => {
const preferredNode = selectedImageNodes[slotIndex] || selectedImageNodes[0];
const attachment = preferredNode
? getCanvasNodeAttachment(preferredNode, `参考图 ${slotIndex + 1}`)
: null;
if (!attachment) return false;
updateAttachmentAtVideoSlot(slotIndex, attachment);
return true;
},
[getCanvasNodeAttachment, selectedImageNodes, updateAttachmentAtVideoSlot]
);
const handleVideoSlotFileChange = useCallback(
async (slotIndex: number, event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@ -3756,6 +3835,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
type="button"
onClick={() => {
if (isDisabled) return;
if (fillVideoSlotFromCanvasSelection(index)) {
toast.success(`${label}已从画布填充`);
return;
}
videoSlotInputRefs.current[index]?.click();
}}
className={cn(
@ -3794,6 +3877,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
);
})}
</div>
<div className="mt-1.5 text-[11px] text-muted-foreground">
已选中画布图片时,点击首帧/尾帧可直接填充;未选中则打开上传。
</div>
</div>
)}