chore: update canvas flow and deploy fixes
This commit is contained in:
parent
e3eb167652
commit
1dc893ee5c
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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/*"
|
||||
|
||||
@ -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
86
lib/net/url-access.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user