feat(canvas): support filling video first/last frame from selected canvas images
This commit is contained in:
parent
cc26d2eb7f
commit
d1518a7606
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user