chore: update canvas flow and deploy fixes
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-24 19:22:42 +08:00
parent e3eb167652
commit 1dc893ee5c
6 changed files with 161 additions and 23 deletions

View File

@ -11,6 +11,7 @@ import {
} from "@/lib/db/ai-model-flags";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { inlineVisionImages } from "@/lib/ai/vision-inline";
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
export const runtime = "nodejs";
export const maxDuration = 180;
@ -1669,10 +1670,37 @@ export async function POST(request: Request) {
}
const baseCount = clampCount(body.count);
const referenceImages = sanitizeReferenceImages(body.referenceImages);
const referenceImagesRaw = sanitizeReferenceImages(body.referenceImages);
const referenceImageNotes = sanitizeReferenceImageNotes(body.referenceImageNotes);
const modelImageUrl = String(body.modelImageUrl || "").trim();
const bundleImageUrls = sanitizeBundleImages(body.bundleImageUrls);
const modelImageUrlRaw = String(body.modelImageUrl || "").trim();
const bundleImageUrlsRaw = sanitizeBundleImages(body.bundleImageUrls);
const [referenceImageCheck, bundleImageCheck, modelImageCheck] = await Promise.all([
filterReachableRemoteUrls(referenceImagesRaw, { timeoutMs: 3000 }),
filterReachableRemoteUrls(bundleImageUrlsRaw, { timeoutMs: 3000 }),
modelImageUrlRaw
? filterReachableRemoteUrls([modelImageUrlRaw], { timeoutMs: 3000 })
: Promise.resolve({ kept: [] as string[], dropped: [] as string[] }),
]);
const referenceImages = referenceImageCheck.kept.slice(0, 12);
const bundleImageUrls = bundleImageCheck.kept.slice(0, 3);
const modelImageUrl = modelImageCheck.kept[0] || "";
if (referenceImageCheck.dropped.length > 0) {
console.warn("[Canvas Detail Plan] Dropping unreachable referenceImages:", {
count: referenceImageCheck.dropped.length,
urls: referenceImageCheck.dropped.slice(0, 6),
});
}
if (bundleImageCheck.dropped.length > 0) {
console.warn("[Canvas Detail Plan] Dropping unreachable bundleImageUrls:", {
count: bundleImageCheck.dropped.length,
urls: bundleImageCheck.dropped.slice(0, 3),
});
}
if (modelImageUrlRaw && !modelImageUrl) {
console.warn("[Canvas Detail Plan] modelImageUrl unreachable, ignore this run:", modelImageUrlRaw);
}
const effectiveReferenceImageNotes =
referenceImages.length === referenceImagesRaw.length ? referenceImageNotes : [];
const hasModelImage = /^https?:\/\//i.test(modelImageUrl);
const hasBundleImages = bundleImageUrls.length > 0;
const count = hasBundleImages ? Math.min(20, baseCount + 1) : baseCount;
@ -1696,7 +1724,7 @@ export async function POST(request: Request) {
language,
brief,
coreFocus,
referenceImageNotes,
referenceImageNotes: effectiveReferenceImageNotes,
hasModelImage,
hasBundleImages,
bundleImageCount: bundleImageUrls.length,
@ -1766,8 +1794,8 @@ export async function POST(request: Request) {
`首屏标题参考(必须承接核心卖点):${coreFocus.firstScreenTitle}`,
"首屏命名规则:标题必须是“名字+需求一句话”(如“甩甩杆,自拍不手抖”),不能只给一个名字。",
"命名风格要求:大白话、易记、朗朗上口,未来可直接成为用户搜索词。",
referenceImageNotes.length > 0
? `参考图说明(按上传顺序):${referenceImageNotes.join(" | ")}`
effectiveReferenceImageNotes.length > 0
? `参考图说明(按上传顺序):${effectiveReferenceImageNotes.join(" | ")}`
: "",
hasModelImage ? "已提供固定模特图,人物场景必须复用该模特。" : "未提供模特图。",
hasBundleImages

View File

@ -348,6 +348,13 @@ export function CanvasWorkspace() {
return containerRef.current.contains(target);
}, []);
const clearBrowserTextSelection = useCallback(() => {
if (typeof window === "undefined") return;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
selection.removeAllRanges();
}, []);
// 处理画布点击(取消选择)
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (hasBlockingDialogOpen()) return;
@ -373,12 +380,14 @@ export function CanvasWorkspace() {
}
if (tool === "select" && e.button === 0 && !isPointOnNodeElement(e.target)) {
e.preventDefault();
clearBrowserTextSelection();
const start = toCanvasPosition(e.clientX, e.clientY);
setIsMarqueeSelecting(true);
setMarqueeStart(start);
setMarqueeEnd(start);
}
}, [hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, toCanvasPosition, tool, viewport]);
}, [clearBrowserTextSelection, hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, toCanvasPosition, tool, viewport]);
// 处理画布平移
const handleMouseMove = useCallback((e: React.MouseEvent) => {
@ -389,6 +398,7 @@ export function CanvasWorkspace() {
if (!isEventFromCanvasContainer(e.target)) return;
dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY);
if (isPanning) {
e.preventDefault();
setViewport({
x: e.clientX - panStart.x,
y: e.clientY - panStart.y,
@ -397,6 +407,7 @@ export function CanvasWorkspace() {
}
if (isMarqueeSelecting) {
e.preventDefault();
setMarqueeEnd(toCanvasPosition(e.clientX, e.clientY));
}
}, [hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, panStart, setViewport, toCanvasPosition]);
@ -406,6 +417,7 @@ export function CanvasWorkspace() {
if (e && !isEventFromCanvasContainer(e.target)) return;
setIsPanning(false);
setSnapGuides(null);
clearBrowserTextSelection();
if (isMarqueeSelecting && marqueeStart && marqueeEnd) {
const left = Math.min(marqueeStart.x, marqueeEnd.x);
@ -440,7 +452,7 @@ export function CanvasWorkspace() {
setIsMarqueeSelecting(false);
setMarqueeStart(null);
setMarqueeEnd(null);
}, [clearSelection, isEventFromCanvasContainer, isMarqueeSelecting, marqueeEnd, marqueeStart, nodes, selectNodes, viewport.x, viewport.y, viewport.zoom]);
}, [clearBrowserTextSelection, clearSelection, isEventFromCanvasContainer, isMarqueeSelecting, marqueeEnd, marqueeStart, nodes, selectNodes, viewport.x, viewport.y, viewport.zoom]);
// 新增图片后自动居中展示
const centerNodeInView = useCallback((nodeId: string) => {

View File

@ -1543,21 +1543,21 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
)}
</div>
) : data.status === 'generating' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
<div className="w-full h-full flex flex-col items-center justify-center bg-muted select-none">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
<span className="text-sm text-muted-foreground text-center px-3">
<span className="text-sm text-muted-foreground text-center px-3 select-none">
{detailProgressLabel || "生成中..."}
</span>
</div>
) : data.status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10">
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10 select-none">
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
<span className="text-sm text-destructive">生成失败</span>
<span className="text-sm text-destructive select-none">生成失败</span>
</div>
) : (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
<div className="w-full h-full flex flex-col items-center justify-center bg-muted select-none">
<ImagePlus className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground mb-3">点击上传图片</span>
<span className="text-sm text-muted-foreground mb-3 select-none">点击上传图片</span>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}

View File

@ -210,22 +210,22 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
)}
</div>
) : data.status === 'generating' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
<div className="w-full h-full flex flex-col items-center justify-center bg-muted select-none">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
<span className="text-sm text-muted-foreground">生成中...</span>
<span className="text-sm text-muted-foreground select-none">生成中...</span>
{data.progress && (
<span className="text-xs text-muted-foreground mt-1">{data.progress}%</span>
<span className="text-xs text-muted-foreground mt-1 select-none">{data.progress}%</span>
)}
</div>
) : data.status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10">
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10 select-none">
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
<span className="text-sm text-destructive">生成失败</span>
<span className="text-sm text-destructive select-none">生成失败</span>
</div>
) : (
<label className="w-full h-full flex flex-col items-center justify-center bg-muted cursor-pointer hover:bg-muted/80 transition-colors">
<label className="w-full h-full flex flex-col items-center justify-center bg-muted cursor-pointer hover:bg-muted/80 transition-colors select-none">
<Video className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">点击上传视频</span>
<span className="text-sm text-muted-foreground select-none">点击上传视频</span>
<input
type="file"
accept="video/*"

View File

@ -5,6 +5,7 @@ import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
import { createFalImage } from "@/lib/ai/providers/fal-image";
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
export interface CanvasGenerateImageRequest {
prompt: string;
@ -112,15 +113,26 @@ export async function generateCanvasImage(
.filter((url) => /^https?:\/\//i.test(url))
)
).slice(0, 12);
const { kept: reachableReferenceImages, dropped: unreachableReferenceImages } =
await filterReachableRemoteUrls(normalizedReferenceImages, { timeoutMs: 3000 });
if (unreachableReferenceImages.length > 0) {
console.warn("[Canvas Generate Image] Dropping unreachable reference images:", {
count: unreachableReferenceImages.length,
urls: unreachableReferenceImages.slice(0, 6),
});
}
if (normalizedReferenceImages.length > 0 && reachableReferenceImages.length === 0) {
throw new Error("参考图片链接不可访问,请重新上传后再试");
}
const messages = [
{
role: "user" as const,
content:
normalizedReferenceImages.length > 0
reachableReferenceImages.length > 0
? [
{ type: "text" as const, text: prompt },
...normalizedReferenceImages.map((url) => ({
...reachableReferenceImages.map((url) => ({
type: "image_url" as const,
image_url: { url },
})),

86
lib/net/url-access.ts Normal file
View File

@ -0,0 +1,86 @@
import "server-only";
type UrlAccessOptions = {
timeoutMs?: number;
};
const DEFAULT_TIMEOUT_MS = 3500;
function withTimeout(timeoutMs: number) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return {
signal: controller.signal,
clear: () => clearTimeout(timer),
};
}
export async function isRemoteUrlReachable(
url: string,
options: UrlAccessOptions = {}
): Promise<boolean> {
const target = String(url || "").trim();
if (!/^https?:\/\//i.test(target)) return false;
const timeoutMs = Math.max(800, Number(options.timeoutMs || DEFAULT_TIMEOUT_MS));
const headCtx = withTimeout(timeoutMs);
try {
const headResponse = await fetch(target, {
method: "HEAD",
redirect: "follow",
cache: "no-store",
signal: headCtx.signal,
});
if (headResponse.ok) return true;
if (![403, 405, 429, 500, 501, 502, 503, 504].includes(headResponse.status)) {
return false;
}
} catch {}
finally {
headCtx.clear();
}
const getCtx = withTimeout(timeoutMs);
try {
const getResponse = await fetch(target, {
method: "GET",
headers: { Range: "bytes=0-0" },
redirect: "follow",
cache: "no-store",
signal: getCtx.signal,
});
return getResponse.ok || getResponse.status === 206;
} catch {
return false;
} finally {
getCtx.clear();
}
}
export async function filterReachableRemoteUrls(
urls: string[],
options: UrlAccessOptions = {}
): Promise<{ kept: string[]; dropped: string[] }> {
const unique = Array.from(
new Set(
(Array.isArray(urls) ? urls : [])
.map((item) => String(item || "").trim())
.filter((item) => /^https?:\/\//i.test(item))
)
);
if (unique.length === 0) {
return { kept: [], dropped: [] };
}
const checks = await Promise.all(
unique.map(async (url) => ({
url,
ok: await isRemoteUrlReachable(url, options),
}))
);
return {
kept: checks.filter((item) => item.ok).map((item) => item.url),
dropped: checks.filter((item) => !item.ok).map((item) => item.url),
};
}