Compare commits

..

No commits in common. "main" and "codex/stylization-load-20261110" have entirely different histories.

24 changed files with 226 additions and 728 deletions

View File

@ -4,8 +4,7 @@ import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
findConfiguredDetailReplacementImageModel,
isDetailReplacementImageModel,
normalizeDetailReplacementImageModelConfig,
} from "@/lib/ai/detail-replacement-image-model-config";
import {
compositeReplacementWithReferenceLocks,
@ -30,6 +29,7 @@ import {
} from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service";
import { bufferToArrayBuffer } from "./persistence";
import { isVideoProviderType } from "./video-models";
import type { AgentPresetMessageImagePart } from "./agent-scene-workflow";
import type { PostRequestBody } from "../schema";
@ -2161,10 +2161,22 @@ export async function resolveDirectReplacementModelRef(params: {
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}) {
const models = params.models || (await getEnabledAiModelsWithProvider(false));
const imageModels = models.filter(
({ model, provider }) =>
model.supportsImageGen &&
!model.isSubModel &&
!isVideoProviderType(provider?.type, model),
);
const configuredModelSetting = await getSystemSetting({
key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
}).catch(() => null);
const configuredModel = findConfiguredDetailReplacementImageModel(models, configuredModelSetting?.value);
const configuredModelId = normalizeDetailReplacementImageModelConfig(
configuredModelSetting?.value,
).modelId;
const configuredModel = imageModels.find(
({ model }) =>
model.id === configuredModelId || model.modelId === configuredModelId,
);
if (configuredModel) {
return configuredModel.model.id;
}
@ -2178,7 +2190,6 @@ export async function resolveDirectReplacementModelRef(params: {
return routedModel;
}
const imageModels = models.filter(isDetailReplacementImageModel);
const preferred =
imageModels.find(({ model }) => model.isDefault) ||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||

View File

@ -10,10 +10,6 @@ import {
} from "@/lib/db/queries";
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability";
import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
findConfiguredDetailReplacementImageModel,
} from "@/lib/ai/detail-replacement-image-model-config";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
@ -161,17 +157,10 @@ export async function GET(request: Request) {
: await getEnabledAiModelsWithProvider(false, true);
const agentModelWithProvider = await getAgentAiModelWithProvider();
const defaultModel = await getDefaultAiModel();
const [siteSettings, agentWorkflowUiConfigSetting, detailReplacementModelSetting] = await Promise.all([
const [siteSettings, agentWorkflowUiConfigSetting] = await Promise.all([
getSiteSettings(),
getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY }).catch(() => null),
getSystemSetting({ key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY }).catch(() => null),
]);
const detailReplacementModel = findConfiguredDetailReplacementImageModel(
includeUnavailable ? modelsWithProvider.filter(({ model, provider }) =>
provider.isEnabled && model.isEnabled && getAiModelFrontendAvailability(model).selectable !== false
) : modelsWithProvider,
detailReplacementModelSetting?.value,
);
type ModelWithProvider = (typeof modelsWithProvider)[number];
const serializeModel = ({ model, provider }: ModelWithProvider) => ({
id: model.id,
@ -320,7 +309,6 @@ export async function GET(request: Request) {
: null,
agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null,
agentWorkflowConfiguredModels,
detailReplacementModel: detailReplacementModel ? serializeModel(detailReplacementModel) : null,
agentModel: agentModelWithProvider
? {
id: agentModelWithProvider.model.id,

View File

@ -4,7 +4,6 @@ import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
import { MAX_DETAIL_REPLACEMENT_REFERENCES } from "@/lib/agents/mode-workspace-state";
import {
createCanvasImageTask,
enqueueCanvasImageTask,
@ -147,14 +146,6 @@ export async function POST(request: Request) {
.map((item) => [String(item.id), String(item.imageUrl)]),
);
const requested = Array.isArray(body.references) ? body.references : [];
if (requested.length === 0) {
return NextResponse.json({ error: "请先选择详情参考图" }, { status: 400 });
}
if (requested.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
return NextResponse.json({
error: `本次选中了 ${requested.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`,
}, { status: 400 });
}
const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
return {
@ -169,6 +160,7 @@ export async function POST(request: Request) {
};
});
if (
references.length === 0 || references.length > 40 ||
new Set(references.map((item) => item.id)).size !== references.length ||
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
) {
@ -195,12 +187,6 @@ export async function POST(request: Request) {
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
}
if (typeof body.expectedModelId === "string" && body.expectedModelId !== model) {
return NextResponse.json({
code: "DETAIL_REPLACEMENT_MODEL_CHANGED",
error: "详情替换模型已更新,请确认页面显示的模型后重新提交",
}, { status: 409 });
}
const quota = await checkUserImageQuota(session.user.id);
if (!quota.allowed) {
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });

View File

@ -976,8 +976,7 @@ export async function POST(request: NextRequest) {
const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
const annotatedUpload = await uploadFile(
annotatedFilename,
toArrayBuffer(annotatedBuffer),
{ preferQiniuForm: true }
toArrayBuffer(annotatedBuffer)
);
const prompt = buildLocalEditPrompt(regions);
const manualRegionReferenceImageUrls = regions.flatMap((region) =>

View File

@ -133,13 +133,15 @@ function buildImageResponse(
etag: string;
vary?: string;
cacheStatus?: "HIT" | "MISS";
cacheControl?: string;
attachment?: boolean;
}
) {
const headers: Record<string, string> = {
"Content-Type": params.contentType,
"Content-Length": String(body.length),
ETag: params.etag,
"Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400",
"Cache-Control": params.cacheControl || "public, max-age=31536000, immutable, stale-while-revalidate=86400",
};
if (params.vary) {
headers.Vary = params.vary;
@ -147,6 +149,11 @@ function buildImageResponse(
if (params.cacheStatus) {
headers["X-Image-Proxy-Cache"] = params.cacheStatus;
}
if (params.attachment) {
headers["Content-Disposition"] = "attachment";
headers["X-Content-Type-Options"] = "nosniff";
headers["Content-Security-Policy"] = "default-src 'none'; sandbox";
}
return new NextResponse(body, {
status: 200,
headers,
@ -453,29 +460,20 @@ export async function GET(request: NextRequest) {
if (original) return downloadFailure("Source is not an image");
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
}
if (original) {
if (upstreamResponse.status !== 200 || !upstreamResponse.body) {
await upstreamResponse.body?.cancel();
return downloadFailure("源图片传输中断,请重试");
}
const headers = new Headers({
"Content-Type": contentType,
"Content-Disposition": "attachment",
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
});
const length = upstreamResponse.headers.get("content-length");
const encoding = upstreamResponse.headers.get("content-encoding");
if (length && (!encoding || encoding.toLowerCase() === "identity")) headers.set("Content-Length", length);
return new NextResponse(upstreamResponse.body, { headers });
}
try {
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
} catch (error) {
return upstreamFailure(error);
}
}
if (original) {
return buildImageResponse(originalBuffer, {
contentType,
etag: `"${crypto.createHash("sha256").update(originalBuffer).digest("hex")}"`,
cacheControl: "private, no-store",
attachment: true,
});
}
const isSvg = contentType.toLowerCase().includes("image/svg");
const isGif = contentType.toLowerCase().includes("image/gif");

View File

@ -6,7 +6,6 @@ import { eq } from "drizzle-orm";
import { uploadFile } from "@/lib/storage/service";
import {
createMediaAssetRelation,
getDefaultStorageConfig,
getPointsSystemConfig,
getMediaAssetsByUserAndUrls,
upsertMediaAsset,
@ -22,27 +21,6 @@ export const maxDuration = 600;
const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000;
const WATERMARK_POLL_INTERVAL_MS = 1200;
async function resolveStoredQiniuImageUrl(source: string): Promise<string | null> {
const config = await getDefaultStorageConfig().catch(() => null);
if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null;
try {
const base = new URL(config.publicUrl);
const url = new URL(source);
const prefix = base.pathname.replace(/\/$/, "") + "/";
if (base.protocol !== "https:" || url.origin !== base.origin ||
url.username || url.password || url.search || url.hash ||
!url.pathname.startsWith(prefix)) return null;
const objectKey = url.pathname.slice(prefix.length);
return /^uploads\/(?:direct\/[0-9a-f-]{36}\/)?[\w.-]+$/i.test(objectKey)
? url.toString()
: null;
} catch {
return null;
}
}
// 获取去水印API配置
async function getWatermarkRemoveConfig() {
try {
@ -153,16 +131,13 @@ async function persistWatermarkResult(params: {
processing_time?: number;
}> {
try {
let resultUrl = await resolveStoredQiniuImageUrl(params.processedImageUrl);
if (!resultUrl) {
const imageResponse = await fetch(params.processedImageUrl);
if (!imageResponse.ok) {
throw new Error("下载处理后的图片失败");
}
const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-removed-${Date.now()}.png`;
resultUrl = (await uploadFile(filename, imageBuffer)).url;
const imageResponse = await fetch(params.processedImageUrl);
if (!imageResponse.ok) {
throw new Error("下载处理后的图片失败");
}
const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-removed-${Date.now()}.png`;
const uploadResult = await uploadFile(filename, imageBuffer);
let saved = false;
let saveError: unknown = null;
@ -175,8 +150,8 @@ async function persistWatermarkResult(params: {
try {
([savedImage] = await db.insert(generatedImage).values({
userId: params.userId,
thumbnailUrl: resultUrl,
originalUrl: resultUrl,
thumbnailUrl: uploadResult.url,
originalUrl: uploadResult.url,
imageSize: "2K",
prompt: "去水印处理",
modelId: "watermark-remove",
@ -209,9 +184,9 @@ async function persistWatermarkResult(params: {
mediaType: "image",
sourceType: "edited",
status: "ready",
originalUrl: resultUrl,
previewUrl: resultUrl,
thumbnailUrl: resultUrl,
originalUrl: uploadResult.url,
previewUrl: uploadResult.url,
thumbnailUrl: uploadResult.url,
displayName: "去水印处理",
prompt: "去水印处理",
modelId: "watermark-remove",
@ -242,8 +217,8 @@ async function persistWatermarkResult(params: {
return {
success: true,
original_url: resultUrl,
thumbnail_url: resultUrl,
original_url: uploadResult.url,
thumbnail_url: uploadResult.url,
processing_time:
typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined,
};
@ -387,7 +362,7 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const { image_url } = body;
if (typeof image_url !== "string" || !image_url) {
if (!image_url) {
return NextResponse.json(
{ error: "请提供图片URL" },
{ status: 400 }
@ -438,24 +413,24 @@ export async function POST(request: NextRequest) {
);
}
}
// 七牛上的公开图片可以直接交给去水印服务读取。
// 如果是 HTTP/HTTPS URL,下载后重新上传确保公网可访问
else if (image_url.startsWith("http://") || image_url.startsWith("https://")) {
const storedImageUrl = await resolveStoredQiniuImageUrl(image_url);
publicImageUrl = storedImageUrl || image_url;
if (!storedImageUrl) {
try {
console.log("[WatermarkRemove] 下载原图并上传到S3...");
const imageResponse = await fetch(image_url);
if (!imageResponse.ok) {
throw new Error("下载原图失败");
}
const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-input-${Date.now()}.jpg`;
publicImageUrl = (await uploadFile(filename, imageBuffer)).url;
console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl);
} catch (downloadError) {
console.error("[WatermarkRemove] 下载/上传原图失败,使用原始URL:", downloadError);
try {
console.log("[WatermarkRemove] 下载原图并上传到S3...");
const imageResponse = await fetch(image_url);
if (!imageResponse.ok) {
throw new Error("下载原图失败");
}
const imageBuffer = await imageResponse.arrayBuffer();
const filename = `watermark-input-${Date.now()}.jpg`;
const uploadResult = await uploadFile(filename, imageBuffer);
publicImageUrl = uploadResult.url;
console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl);
} catch (downloadError) {
console.error("[WatermarkRemove] 下载/上传原图失败:", downloadError);
// 如果下载失败,尝试使用原始URL
console.log("[WatermarkRemove] 尝试使用原始URL");
publicImageUrl = image_url;
}
} else {
return NextResponse.json(

View File

@ -2294,9 +2294,8 @@ export default function CanvasEditorLegacyPage({
loadedAgentProjectId === agentProjectId;
const [workflowTemplateMarker, setWorkflowTemplateMarker] = useState<HTMLDivElement | null>(null);
const [workflowTemplatesRequested, setWorkflowTemplatesRequested] = useState(false);
const hasWorkflowBase = Boolean(siteSettingsData?.agentWorkflowUiConfig);
useEffect(() => {
if (!isAgentMode || !hasActiveAgentProject || !hasWorkflowBase || workflowTemplatesRequested) return;
if (!isAgentMode || !hasActiveAgentProject || workflowTemplatesRequested) return;
const element = workflowTemplateMarker;
if (!element) return;
const observer = new IntersectionObserver(([entry]) => {
@ -2304,11 +2303,11 @@ export default function CanvasEditorLegacyPage({
});
observer.observe(element);
return () => observer.disconnect();
}, [hasActiveAgentProject, hasWorkflowBase, isAgentMode, workflowTemplateMarker, workflowTemplatesRequested]);
}, [hasActiveAgentProject, isAgentMode, workflowTemplateMarker, workflowTemplatesRequested]);
const { data: workflowTemplates, error: workflowTemplatesError, isValidating: workflowTemplatesLoading, mutate: reloadWorkflowTemplates } = useSWR<{
steps: AgentWorkflowUiConfig["steps"];
}>(
isAgentMode && hasActiveAgentProject && hasWorkflowBase && workflowTemplatesRequested
isAgentMode && hasActiveAgentProject && siteSettingsData?.agentWorkflowUiConfig && workflowTemplatesRequested
? "/api/site-settings?scope=agent-workflow-templates"
: null,
fetchCanvasSiteSettings,

View File

@ -159,7 +159,6 @@ import type {
} from "@/types/agent";
import {
createEmptyAgentModeWorkspaceState,
MAX_DETAIL_REPLACEMENT_REFERENCES,
} from "@/lib/agents/mode-workspace-state";
import {
createEmptyAgentGenerationProjectWorkspaceState,
@ -214,7 +213,6 @@ export interface AgentGenerationWorkspaceDarkV2Props {
interface AgentWorkflowModelOption {
id: string;
displayName: string;
providerName?: string;
providerType: string;
supportsImageGen?: boolean;
isMultimodal?: boolean;
@ -2645,9 +2643,7 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
? record.requestedCardIds
.map((entry) => String(entry || "").trim())
.filter(Boolean)
.slice(0, record.modeType === "detail_replacement"
? MAX_DETAIL_REPLACEMENT_REFERENCES
: MAX_AGENT_MODE_REFERENCE_COUNT)
.slice(0, MAX_AGENT_MODE_REFERENCE_COUNT)
: null,
} satisfies LocalGenerationPendingState;
});
@ -3388,13 +3384,12 @@ export function AgentGenerationWorkspaceDarkV2({
}
);
const { data: modelsData, error: modelsError, mutate: refreshModels } = useSWR<{
const { data: modelsData } = useSWR<{
models: AgentWorkflowModelOption[];
defaultModelId?: string | null;
agentWorkflowConfiguredDefaultModelId?: string | null;
agentWorkflowDefaultModelId?: string | null;
agentWorkflowConfiguredModels?: AgentWorkflowModelOption[];
detailReplacementModel?: AgentWorkflowModelOption | null;
agentModel?: any;
}>("/api/models", fetcher);
@ -3440,6 +3435,17 @@ export function AgentGenerationWorkspaceDarkV2({
const activeBusinessModeDefaultModelId = !requiresPresetSelection
? String(activeBusinessMode?.defaultModelId || "").trim() || null
: null;
const lockedWorkflowModelId =
activeBusinessModeDefaultModelId ||
selectedAgentPreset?.defaultModelId ||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
modelsData?.agentWorkflowDefaultModelId ||
null;
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
const effectiveDefaultModelId =
lockedWorkflowModelId ||
modelsData?.defaultModelId ||
null;
const presetDrivenResultModeType: AgentGenerationResultModeType =
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement"
? detailReplacementMethod === "ai_detail_generate"
@ -3455,19 +3461,6 @@ export function AgentGenerationWorkspaceDarkV2({
const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection
? presetDrivenResultModeType
: businessModeResultModeType;
const detailReplacementModel = currentResultModeType === "detail_replacement"
? modelsData?.detailReplacementModel
: null;
const isDetailReplacementModelLoading = currentResultModeType === "detail_replacement" && !modelsData;
const lockedWorkflowModelId =
detailReplacementModel?.id ||
activeBusinessModeDefaultModelId ||
selectedAgentPreset?.defaultModelId ||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
modelsData?.agentWorkflowDefaultModelId ||
null;
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
const effectiveDefaultModelId = lockedWorkflowModelId || modelsData?.defaultModelId || null;
const isSceneGenerationPreset = currentResultModeType === "scene_generation";
const isDetailReplacementPreset =
currentResultModeType === "detail_replacement" ||
@ -4119,15 +4112,12 @@ export function AgentGenerationWorkspaceDarkV2({
);
const executableModelOptions = useMemo(() => {
const seen = new Set<string>();
const workflowModels = detailReplacementModel
? [detailReplacementModel, ...configuredWorkflowModels]
: configuredWorkflowModels;
return [...selectableModels, ...workflowModels].filter((model) => {
return [...selectableModels, ...configuredWorkflowModels].filter((model) => {
if (seen.has(model.id)) return false;
seen.add(model.id);
return true;
});
}, [configuredWorkflowModels, detailReplacementModel, selectableModels]);
}, [configuredWorkflowModels, selectableModels]);
const executableModelIdSet = useMemo(
() => new Set(executableModelOptions.map((model) => model.id)),
[executableModelOptions]
@ -4146,9 +4136,8 @@ export function AgentGenerationWorkspaceDarkV2({
selectableModels[0],
[executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId]
);
const lockedWorkflowModelLabel = detailReplacementModel?.providerName
? `${detailReplacementModel.displayName} 「${detailReplacementModel.providerName}」`
: lockedWorkflowModel?.displayName ||
const lockedWorkflowModelLabel =
lockedWorkflowModel?.displayName ||
selectedAgentPreset?.defaultModel?.displayName ||
(lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置");
const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("settings");
@ -6327,15 +6316,6 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`);
return;
}
if (isDetailReplacementModelLoading) {
if (modelsError) {
toast.error("详情替换模型加载失败,正在重新读取");
void refreshModels().catch(() => {});
} else {
toast.info("正在读取详情替换模型,请稍候");
}
return;
}
if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) {
toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
return;
@ -6403,10 +6383,6 @@ export function AgentGenerationWorkspaceDarkV2({
}
if (replacementModeType === "detail_replacement") {
if (detailBatchSubmittingRef.current) return;
if (replacementExecutionCards.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
toast.error(`本次选中了 ${replacementExecutionCards.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`);
return;
}
if (!isValidChatUuid(projectId)) {
toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换");
return;
@ -6427,14 +6403,13 @@ export function AgentGenerationWorkspaceDarkV2({
const saved = await onPersistProject?.();
if (saved === false) throw new Error("项目尚未保存,请重试");
const requestId = generateUUID();
const response = await fetch("/api/agents/detail-replacement/batches", {
const response = await fetchWithErrorHandlers("/api/agents/detail-replacement/batches", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId,
requestId,
model: resolvedModelId,
expectedModelId: resolvedModelId === "auto" ? undefined : resolvedModelId,
imageSize: imageParams.imageSize,
replacementStrategy: detailReplacementStrategy,
prompt: detailReplacementStrategy === "product_only"
@ -6458,7 +6433,6 @@ export function AgentGenerationWorkspaceDarkV2({
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.task?.id) {
if (payload?.code === "DETAIL_REPLACEMENT_MODEL_CHANGED") void refreshModels().catch(() => {});
throw new Error(payload?.error || "详情替换任务提交失败");
}
setPendingGeneration({
@ -6646,8 +6620,6 @@ export function AgentGenerationWorkspaceDarkV2({
input,
isDetailAiGenerationMethod,
isDetailReplacementPreset,
isDetailReplacementModelLoading,
modelsError,
isSelectedBusinessModeUnavailable,
isSkuReplacementPreset,
effectiveDefaultModelId,
@ -6675,7 +6647,6 @@ export function AgentGenerationWorkspaceDarkV2({
onPersistProject,
projectId,
refreshDetailBatch,
refreshModels,
]);
const handleStopGeneration = useCallback(() => {
detailGenerationCancelRef.current = true;
@ -9448,11 +9419,9 @@ export function AgentGenerationWorkspaceDarkV2({
</Select>
) : (
<div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70">
{isDetailReplacementModelLoading
? modelsError ? "详情替换模型加载失败,点击批量替换可重试" : "正在读取详情替换模型"
: isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
{isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
<span className="ml-2 font-medium text-white">
{isDetailReplacementModelLoading ? null : isWorkflowModelLocked
{isWorkflowModelLocked
? lockedWorkflowModelLabel
: currentModel?.displayName || "自动选择"}
</span>

View File

@ -3,7 +3,6 @@
import { useMemo } from "react";
import useSWR from "swr";
import { Check, ImageIcon, Loader2, UserRound } from "lucide-react";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button";
import { cn, fetcher } from "@/lib/utils";
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
@ -230,11 +229,12 @@ export function AgentWorkflowBrandModelPicker({
isDark ? "bg-white/[0.04]" : "bg-muted/40"
)}
>
<AgentPreviewImage
<img
src={image.imageUrl}
alt={image.displayName || `${normalizedBrand} 模特图`}
className="h-full w-full object-cover"
previewWidth={360}
loading="lazy"
decoding="async"
/>
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-2 py-2">
<span className="rounded-full bg-black/60 px-2 py-1 text-[11px] text-white">

View File

@ -78,7 +78,7 @@ import {
type UploadProgress,
} from "@/lib/client/upload-with-retry";
import {
downloadOriginalImage,
downloadCanvasImageAsPng,
getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download";
@ -1462,7 +1462,7 @@ function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
}
// 获取图片尺寸(保留原图分辨率 + 画布显示尺寸)
function getImageDimensions(source: string | File): Promise<{
function getImageDimensions(url: string): Promise<{
width: number;
height: number;
originalWidth: number;
@ -1470,7 +1470,6 @@ function getImageDimensions(source: string | File): Promise<{
}> {
return new Promise((resolve) => {
const img = new Image();
const objectUrl = typeof source === "string" ? null : URL.createObjectURL(source);
let settled = false;
const resolveOnce = (dimensions: {
width: number;
@ -1483,11 +1482,9 @@ function getImageDimensions(source: string | File): Promise<{
window.clearTimeout(timeout);
img.onload = null;
img.onerror = null;
if (objectUrl) URL.revokeObjectURL(objectUrl);
resolve(dimensions);
};
const timeout = window.setTimeout(() => {
img.removeAttribute("src");
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
}, CANVAS_IMAGE_DIMENSION_TIMEOUT_MS);
@ -1520,7 +1517,7 @@ function getImageDimensions(source: string | File): Promise<{
// 加载失败时使用默认尺寸
resolveOnce(DEFAULT_CANVAS_IMAGE_DIMENSIONS);
};
img.src = objectUrl ?? String(source);
img.src = url;
});
}
@ -4471,8 +4468,9 @@ export function CanvasChatPanel({
});
try {
await downloadOriginalImage({
await downloadCanvasImageAsPng({
url: originalUrl,
candidates: [raw, buildImageProxyUrl(originalUrl, { delivery: "proxy" })],
signal: controller.signal,
onProgress: setDownloadProgress,
});
@ -7808,6 +7806,7 @@ export function CanvasChatPanel({
existingMessageImageKeys.add(messageImageKey);
}
const dimensions = await getImageDimensions(url);
if (useCanvasStore.getState().projectId !== canvasId || activeChatIdRef.current !== activeChatId) return;
const compareSourceUrlRaw =
sourceImageUrls[Math.min(index, Math.max(0, sourceImageUrls.length - 1))] ||
@ -7840,7 +7839,7 @@ export function CanvasChatPanel({
const placeholderNode = reservedPlaceholderNode
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
: null;
const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 };
const size = { width: dimensions.width, height: dimensions.height };
const position = placeholderNode
? getCenteredReplacementPosition(placeholderNode, size)
: getAnchoredGenerationPosition({
@ -7858,6 +7857,8 @@ export function CanvasChatPanel({
prompt: generationPrompt,
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt,
sourceMessageId: messageId || undefined,
sourceImageKey,
@ -9582,6 +9583,7 @@ export function CanvasChatPanel({
continue;
}
const dimensions = await getImageDimensions(url);
if (cancelled) return;
const compareSourceUrlRaw =
nearestUserImageUrls[Math.min(index, Math.max(0, nearestUserImageUrls.length - 1))] ||
@ -9614,7 +9616,20 @@ export function CanvasChatPanel({
const placeholderNode = reservedPlaceholderNode
? currentNodes.find((node) => node.id === reservedPlaceholderNode.id) || null
: null;
const size = placeholderNode?.size || anchorNode?.size || { width: 300, height: 300 };
const size = placeholderNode
? { width: dimensions.width, height: dimensions.height }
: anchorNode
? {
width: Math.max(
1,
Math.round(Number(anchorNode.size?.width || dimensions.width))
),
height: Math.max(
1,
Math.round(Number(anchorNode.size?.height || dimensions.height))
),
}
: { width: dimensions.width, height: dimensions.height };
const position = placeholderNode
? getCenteredReplacementPosition(placeholderNode, size)
: getAnchoredGenerationPosition({
@ -9633,6 +9648,8 @@ export function CanvasChatPanel({
prompt: generationPrompt || "对话生成",
originalSrc: canUseCompareSource ? compareSourceUrl : undefined,
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt: generationPrompt || undefined,
sourceMessageId: message.id,
sourceImageKey: imageEntry.sourceImageKey,
@ -10359,7 +10376,7 @@ export function CanvasChatPanel({
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
);
const dimensions = await getImageDimensions(file);
const dimensions = await getImageDimensions(imageUrl);
const nodeIndex = nodes.length + slotIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
@ -10437,7 +10454,7 @@ export function CanvasChatPanel({
buildCanvasUploadedImageAttachment({ file, imageUrl, assetId })
);
const dimensions = await getImageDimensions(file);
const dimensions = await getImageDimensions(imageUrl);
const nodeIndex = nodes.length + slotIndex;
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
@ -13723,7 +13740,7 @@ export function CanvasChatPanel({
};
onUploaded?.(uploadedBase);
clearComposerUploadProgress(250);
const dimensions = await getImageDimensions(file);
const dimensions = await getImageDimensions(imageUrl);
const spacing = 20;
const x = 100 + (nodeIndex % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(nodeIndex / 3) * (dimensions.height + spacing);
@ -14912,7 +14929,7 @@ export function CanvasChatPanel({
firstImageUrl = imageUrl;
}
const dimensions = await getImageDimensions(file);
const dimensions = await getImageDimensions(imageUrl);
updateUploadedImageAttachmentDimensions(imageUrl, dimensions);
// 获取图片原始尺寸后添加到画布

View File

@ -179,7 +179,6 @@ import {
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
downloadCanvasImageAsPng,
downloadOriginalImage,
getCanvasImageDownloadErrorMessage,
type CanvasImageDownloadProgress,
} from "@/lib/client/canvas-image-download";
@ -2483,11 +2482,14 @@ export function ImageNode({
[compareButtonVisualSize]
);
const shouldUseHighFidelityDisplayImage =
isSelected ||
isHovered ||
isCompareMode ||
boxCutoutMode ||
localEditMode ||
cropPanelOpen ||
imageAdjustPanelOpen ||
fullscreenPreviewOpen ||
isThisNodePicking;
const displayImageWidth = useMemo(() => {
const devicePixelRatio =
@ -2520,6 +2522,15 @@ export function ImageNode({
}),
[data.src, displayImageQuality, displayImageWidth]
);
const fullscreenPreviewSrc = useMemo(() => {
const originalUrl = resolveOriginalImageUrl(data.src || "");
return buildImageProxyUrl(originalUrl || data.src || "", {
delivery: "proxy",
width: 4096,
quality: 92,
format: "webp",
});
}, [data.src]);
const displayCompareSrc = useMemo(
() =>
buildImageProxyUrl(compareSource, {
@ -8224,8 +8235,9 @@ export function ImageNode({
}
};
// 下载图片 - 服务端统一转为 Photoshop 兼容 PNG,避免后缀和真实格式错配。
const isDownloadingOriginalImage = Boolean(downloadProgress);
const handleDownload = async (format: "original" | "png" = "original") => {
const handleDownload = async () => {
if (!data.src) return;
if (isDownloadingOriginalImage) {
toast.info("原图正在下载中");
@ -8241,15 +8253,20 @@ export function ImageNode({
});
try {
const request = {
url: resolveOriginalImageUrl(data.src) || data.src,
await downloadCanvasImageAsPng({
url: data.src,
name: displayTitle,
candidates: [
displayImageSrc,
fullscreenPreviewSrc,
buildImageProxyUrl(resolveOriginalImageUrl(data.src) || data.src, {
delivery: "proxy",
}),
],
signal: controller.signal,
onProgress: setDownloadProgress,
};
if (format === "png") await downloadCanvasImageAsPng({ ...request, url: data.src });
else await downloadOriginalImage(request);
toast.success(format === "png" ? "PNG 已开始下载" : "原图已开始下载");
});
toast.success("原图已开始下载");
} catch (error) {
if (controller.signal.aborted) {
toast.message("已取消下载");
@ -9642,7 +9659,6 @@ export function ImageNode({
<DropdownMenuSeparator />
) : null}
{imageDownloadEnabled ? (
<>
<DropdownMenuItem
disabled={isDownloadingOriginalImage}
onClick={() => {
@ -9660,11 +9676,6 @@ export function ImageNode({
)}
{imageDownloadItem.label}
</DropdownMenuItem>
<DropdownMenuItem disabled={isDownloadingOriginalImage} onClick={() => void handleDownload("png")}>
<Download className="mr-2 h-4 w-4" />
下载为 PNG(兼容 Photoshop)
</DropdownMenuItem>
</>
) : null}
{imageDeleteEnabled ? (
<DropdownMenuItem

View File

@ -14,8 +14,6 @@ import {
sanitizeAgentDetailBrief,
} from "@/lib/agents/detail-generation-config";
export const MAX_DETAIL_REPLACEMENT_REFERENCES = 64;
export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState {
return {
detailReplacement: {

View File

@ -1,5 +1,3 @@
import { isVideoModelDescriptor } from "@/lib/ai/model-classification";
export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY =
"detail_replacement_image_model_config";
@ -22,28 +20,3 @@ export function normalizeDetailReplacementImageModelConfig(
modelId: modelId || DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID,
};
}
export function isDetailReplacementImageModel({
model,
provider,
}: {
model: { modelId: string; supportsImageGen: boolean; isSubModel: boolean; capabilities?: unknown };
provider?: { type?: string | null } | null;
}) {
return model.supportsImageGen && !model.isSubModel && !isVideoModelDescriptor({
providerType: provider?.type,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
export function findConfiguredDetailReplacementImageModel<
T extends Parameters<typeof isDetailReplacementImageModel>[0] & { model: { id: string } },
>(models: T[], setting: unknown): T | undefined {
const { modelId } = normalizeDetailReplacementImageModelConfig(setting);
return models.find((entry) =>
(entry.model.id === modelId || entry.model.modelId === modelId) &&
isDetailReplacementImageModel(entry)
);
}

View File

@ -1,52 +1,37 @@
import { createFalClient } from "@fal-ai/client";
import sharp from "sharp";
export const FAL_OUTPAINT_MODEL = "fal-ai/flux-2-pro/outpaint";
export const FIBO_EXPAND_MODEL = "bria/fibo-edit-1.5/expand";
export const FIBO_EXPAND_PROMPT = "Continue the same scene edge to edge, matching the existing lighting, colors, texture, perspective and cut lines. Preserve the original subject and all lettering; continue any caption background that reaches the side edges.";
export function getOutpaintGeometry(width: number, height: number, xScale: number, yScale: number) {
export function getFiboGeometry(width: number, height: number, xScale: number, yScale: number) {
const targetWidth = Math.round(width * xScale);
const targetHeight = Math.round(height * yScale);
const ratio = Math.min(1, 2048 / Math.max(targetWidth, targetHeight));
const ratio = Math.min(1, Math.sqrt(24_000_000 / (targetWidth * targetHeight)));
const modelWidth = Math.max(1, Math.floor(targetWidth * ratio));
const modelHeight = Math.max(1, Math.floor(targetHeight * ratio));
const sourceWidth = Math.max(1, Math.round(width * modelWidth / targetWidth));
const sourceHeight = Math.max(1, Math.round(height * modelHeight / targetHeight));
const left = Math.floor((targetWidth - width) / 2);
const top = Math.floor((targetHeight - height) / 2);
const modelLeft = Math.round(left * modelWidth / targetWidth);
const modelTop = Math.round(top * modelHeight / targetHeight);
// Align the model canvas to its 16-pixel grid, then remove the extra border.
const extraWidth = (16 - modelWidth % 16) % 16;
const extraHeight = (16 - modelHeight % 16) % 16;
const cropLeft = Math.floor(extraWidth / 2);
const cropTop = Math.floor(extraHeight / 2);
return {
targetWidth, targetHeight, left, top, modelWidth, modelHeight, sourceWidth, sourceHeight,
paddedWidth: modelWidth + extraWidth,
paddedHeight: modelHeight + extraHeight,
cropLeft,
cropTop,
expandLeft: modelLeft + cropLeft,
expandRight: modelWidth - sourceWidth - modelLeft + extraWidth - cropLeft,
expandTop: modelTop + cropTop,
expandBottom: modelHeight - sourceHeight - modelTop + extraHeight - cropTop,
modelLeft: Math.round(left * modelWidth / targetWidth),
modelTop: Math.round(top * modelHeight / targetHeight),
};
}
export async function composeOutpaint(source: Buffer, expanded: Buffer, geometry: ReturnType<typeof getOutpaintGeometry>) {
const { targetWidth, targetHeight, left, top, modelWidth, modelHeight, paddedWidth, paddedHeight, cropLeft, cropTop } = geometry;
export async function composeFiboExpansion(source: Buffer, expanded: Buffer, geometry: ReturnType<typeof getFiboGeometry>) {
const { targetWidth, targetHeight, left, top, modelWidth, modelHeight } = geometry;
const modelImage = sharp(expanded);
const info = await modelImage.metadata();
if (info.width !== paddedWidth || info.height !== paddedHeight) {
if (info.width !== modelWidth || info.height !== modelHeight) {
throw new Error("扩图服务返回的图片尺寸不正确");
}
// Copy the source rectangle, including transparent pixels, without blending or resizing it.
const { data: original, info: sourceInfo } = await sharp(source).toColourspace("srgb").ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const result = await modelImage
.extract({ left: cropLeft, top: cropTop, width: modelWidth, height: modelHeight })
.resize(targetWidth, targetHeight, { fit: "fill" })
.toColourspace("srgb").ensureAlpha().raw().toBuffer();
const result = await modelImage.resize(targetWidth, targetHeight, { fit: "fill" }).toColourspace("srgb").ensureAlpha().raw().toBuffer();
const stride = sourceInfo.width * 4;
for (let row = 0; row < sourceInfo.height; row++) {
original.copy(result, ((top + row) * targetWidth + left) * 4, row * stride, (row + 1) * stride);
@ -54,7 +39,7 @@ export async function composeOutpaint(source: Buffer, expanded: Buffer, geometry
return sharp(result, { raw: { width: targetWidth, height: targetHeight, channels: 4 } }).png().toBuffer();
}
export async function expandWithFalOutpaint({ imageUrl, apiKey, xScale, yScale }: {
export async function expandWithFibo({ imageUrl, apiKey, xScale, yScale }: {
imageUrl: string;
apiKey: string;
xScale: number;
@ -64,30 +49,26 @@ export async function expandWithFalOutpaint({ imageUrl, apiKey, xScale, yScale }
if (!response.ok) throw new Error("无法读取待扩展的原图");
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
.rotate().png().toBuffer({ resolveWithObject: true });
const geometry = getOutpaintGeometry(info.width, info.height, xScale, yScale);
const geometry = getFiboGeometry(info.width, info.height, xScale, yScale);
if (geometry.targetWidth * geometry.targetHeight > 64_000_000) {
throw new Error("扩图后的尺寸过大,请降低扩展倍数");
}
const fal = createFalClient({ credentials: apiKey });
const modelSource = geometry.sourceWidth === info.width && geometry.sourceHeight === info.height
? source
: await sharp(source).resize(geometry.sourceWidth, geometry.sourceHeight, { fit: "fill" }).png().toBuffer();
const image = await fal.storage.upload(new Blob([new Uint8Array(modelSource)], { type: "image/png" }));
const { data } = await fal.subscribe(FAL_OUTPAINT_MODEL, {
const image = await fal.storage.upload(new Blob([new Uint8Array(source)], { type: "image/png" }));
const { data } = await fal.subscribe(FIBO_EXPAND_MODEL, {
input: {
image_url: image,
expand_left: geometry.expandLeft,
expand_right: geometry.expandRight,
expand_top: geometry.expandTop,
expand_bottom: geometry.expandBottom,
mode: "high",
output_format: "png",
canvas_size: [geometry.modelWidth, geometry.modelHeight],
original_image_size: [geometry.sourceWidth, geometry.sourceHeight],
original_image_location: [geometry.modelLeft, geometry.modelTop],
retain_original_quality: true,
prompt: FIBO_EXPAND_PROMPT,
},
timeout: 90_000,
});
const outputUrl = (data as { images?: Array<{ url?: string }> }).images?.[0]?.url;
const outputUrl = (data as { image?: { url?: string } }).image?.url;
if (!outputUrl) throw new Error("扩图服务未返回图片");
const output = await fetch(outputUrl, { signal: AbortSignal.timeout(25_000) });
if (!output.ok) throw new Error("无法读取扩图结果");
return composeOutpaint(source, Buffer.from(await output.arrayBuffer()), geometry);
return composeFiboExpansion(source, Buffer.from(await output.arrayBuffer()), geometry);
}

View File

@ -6,7 +6,7 @@ import {
upsertMediaAsset,
} from "@/lib/db/queries";
import { type ExtendProvider, resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
import { expandWithFalOutpaint, FAL_OUTPAINT_MODEL } from "@/lib/ai/extend/fal-outpaint";
import { expandWithFibo, FIBO_EXPAND_MODEL } from "@/lib/ai/extend/fibo";
import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
@ -536,8 +536,8 @@ export async function executeExtendJob(
let buffer: Buffer;
let modelId: string;
if (config.provider === "fal") {
buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale });
modelId = FAL_OUTPAINT_MODEL;
buffer = await expandWithFibo({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale });
modelId = FIBO_EXPAND_MODEL;
} else {
const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl);
const url = await extendImage(prepared, config, xScale, yScale, requestId);

View File

@ -99,7 +99,6 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 8;
const DEFAULT_MAX_POLL_SECONDS = 600;
const REFERENCE_DOWNLOAD_ATTEMPTS = 3;
const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000;
const REFERENCE_CDN_TIMEOUT_MS = 10_000;
const REFERENCE_CACHE_LOOKUP_TIMEOUT_MS = 15_000;
const MAX_REFERENCE_DOWNLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES;
const REFERENCE_STAGING_CACHE_MS = 60_000;
@ -683,44 +682,43 @@ async function findStagedReference(
}
}
async function fetchReferenceImage(mediaUrl: string, preferCdn = false) {
const response = await fetch(mediaUrl, {
cache: preferCdn ? "default" : "no-store",
redirect: preferCdn ? "error" : "follow",
headers: { "User-Agent": "yedir-ai-vip98k-staging/1.0" },
signal: AbortSignal.timeout(preferCdn ? REFERENCE_CDN_TIMEOUT_MS : REFERENCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok || (preferCdn && response.status !== 200)) {
await response.body?.cancel();
throw new Error(`source returned HTTP ${response.status}`);
}
const mimeType = response.headers.get("content-type") || "image/png";
if (preferCdn && !/^image\//i.test(mimeType) && !/^application\/octet-stream\b/i.test(mimeType)) {
await response.body?.cancel();
throw new Error("CDN response was not an image");
}
const declaredLength = Number(response.headers.get("content-length"));
if (declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
await response.body?.cancel();
throw new Vip98kReferenceTooLargeError();
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
throw new Vip98kReferenceTooLargeError();
}
if ((declaredLength > 0 && buffer.byteLength !== declaredLength) || (preferCdn && !buffer.byteLength)) {
throw new Error("source image download was incomplete");
}
return { buffer, mimeType };
}
async function downloadReferenceImage(mediaUrl: string) {
let lastError: unknown;
for (let attempt = 1; attempt <= REFERENCE_DOWNLOAD_ATTEMPTS; attempt += 1) {
try {
return await fetchReferenceImage(mediaUrl);
const response = await fetch(mediaUrl, {
cache: "no-store",
redirect: "follow",
headers: { "User-Agent": "yedir-ai-vip98k-staging/1.0" },
signal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`source returned HTTP ${response.status}`);
}
const declaredLength = Number(response.headers.get("content-length"));
if (
Number.isFinite(declaredLength) &&
declaredLength > MAX_REFERENCE_DOWNLOAD_BYTES
) {
throw new Vip98kReferenceTooLargeError();
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength > MAX_REFERENCE_DOWNLOAD_BYTES) {
throw new Vip98kReferenceTooLargeError();
}
if (
Number.isFinite(declaredLength) &&
declaredLength > 0 &&
buffer.byteLength !== declaredLength
) {
throw new Error("source image download was incomplete");
}
return {
buffer,
mimeType: response.headers.get("content-type") || "image/png",
};
} catch (error) {
lastError = error;
if (error instanceof Vip98kReferenceTooLargeError) throw error;
@ -740,22 +738,6 @@ async function downloadReferenceImage(mediaUrl: string) {
);
}
async function downloadReferenceImageFromCdn(mediaUrl: string, stagingConfig: ReferenceStagingConfig) {
if (stagingConfig.storageConfig.provider !== "qiniu-kodo") return null;
const cdnUrl = process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "";
if (!URL.canParse(cdnUrl) || !getStorageObjectKeyFromPublicUrl(mediaUrl, stagingConfig.sourcePublicUrl)) return null;
const source = new URL(mediaUrl);
if (source.origin !== new URL(cdnUrl).origin || source.search || source.username || source.password) return null;
try {
return await fetchReferenceImage(mediaUrl, true);
} catch (error) {
if (error instanceof Vip98kReferenceTooLargeError) throw error;
logger.warn("reference CDN download failed; trying source storage", { sourceHost: source.hostname });
return null;
}
}
async function downloadReferenceImageFromStorage(
mediaUrl: string,
stagingConfig: ReferenceStagingConfig,
@ -870,10 +852,9 @@ async function stageReferenceImage(
}
const downloadStartedAt = Date.now();
const cdn = await downloadReferenceImageFromCdn(mediaUrl, stagingConfig);
const storage = cdn ? null : await downloadReferenceImageFromStorage(mediaUrl, stagingConfig);
const downloaded =
cdn || storage || (await downloadReferenceImage(mediaUrl));
(await downloadReferenceImageFromStorage(mediaUrl, stagingConfig)) ||
(await downloadReferenceImage(mediaUrl));
const downloadMs = Date.now() - downloadStartedAt;
const normalizeStartedAt = Date.now();
const normalized = await normalizeReferenceImage(
@ -891,7 +872,6 @@ async function stageReferenceImage(
cacheStagedReference(cacheKey, uploadedUrl, runtimeConfig);
logger.info("reference image staged", {
sourceHost: getUrlHost(mediaUrl),
sourceDelivery: cdn ? "cdn" : storage ? "storage" : "public-url",
targetHost: getUrlHost(uploadedUrl),
sourceBytes: downloaded.buffer.byteLength,
stagedBytes: normalized.buffer.byteLength,

View File

@ -3444,13 +3444,11 @@ async function applyLocalEditCompositeToImages(params: {
const [originalUpload, thumbnailUpload] = await Promise.all([
uploadFile(
`canvas-local-edit-composited-${suffix}.png`,
bufferToArrayBuffer(mergedBuffer),
{ preferQiniuForm: true }
bufferToArrayBuffer(mergedBuffer)
),
uploadFile(
`canvas-local-edit-composited-thumb-${suffix}.png`,
bufferToArrayBuffer(thumbnailBuffer),
{ preferQiniuForm: true }
bufferToArrayBuffer(thumbnailBuffer)
),
]);
mergedImages.push({

View File

@ -1,15 +1,6 @@
import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage";
const IMAGE_PROXY_PATH = "/api/image-proxy";
const CDN_PREVIEW_WIDTHS = [64, 160, 192, 240, 320, 360, 480, 520, 640, 720, 840, 960, 1280, 1600, 2048, 2560, 2600, 3200, 4096];
function getConfiguredCdnUrl(): URL | undefined {
try {
return new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "");
} catch {
return undefined;
}
}
/** Error fallbacks must keep unavailable legacy sources behind archive resolution. */
export function buildImageFallbackUrl(value: string | null | undefined): string {
@ -111,7 +102,10 @@ export function isAlibabaModelImageUrl(value: string): boolean {
function isTrustedDirectStorageImageHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
let configuredCdnHost = "";
try { configuredCdnHost = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "").hostname; } catch { /* optional */ }
return (
(configuredCdnHost !== "" && normalized === configuredCdnHost) ||
normalized === "yybb.top" ||
normalized.endsWith(".qiniucs.com") ||
normalized.endsWith(".qiniudn.com") ||
@ -133,29 +127,20 @@ function resolveTrustedDirectStorageDisplayUrl(
): string | null {
try {
const target = new URL(url);
const configuredCdn = getConfiguredCdnUrl();
const isConfiguredCdn = target.origin === configuredCdn?.origin;
if (
target.hostname !== configuredCdn?.hostname &&
!isTrustedDirectStorageImageHost(target.hostname) &&
!isFirstPartyQiniuAsset(target)
) {
return null;
}
const requestedWidth = normalizeWidth(options.width);
let width = requestedWidth;
let quality = normalizeQuality(options.quality);
const width = normalizeWidth(options.width);
const quality = normalizeQuality(options.quality);
const format = options.format;
if ((!width && !quality && !format) || target.search) {
return target.toString();
}
if (isConfiguredCdn && format === "webp" && requestedWidth) {
width = CDN_PREVIEW_WIDTHS.find((size) => size >= requestedWidth) ?? requestedWidth;
if (quality && quality >= 84 && quality <= 92) quality = 88;
}
const operations = ["imageView2", "2"];
if (width) operations.push("w", String(width));
if (format) operations.push("format", format === "jpeg" ? "jpg" : format);

View File

@ -4,7 +4,6 @@ import { findImageArchiveDownloadTask } from "./image-archive-db";
import { inferOriginalImageUrl } from "@/lib/image-url";
import { getImageDownloadExtension } from "@/lib/image-delivery";
import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download";
import { HeadObjectCommand } from "@aws-sdk/client-s3";
/** Resolve the archived original without waiting for a separate PNG conversion. */
export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) {
@ -29,32 +28,8 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s
// Only metadata crosses the application server. The browser receives the file
// from the CDN, including when cross-origin fetch is disabled by storage CORS.
const response = await fetch(target, {
method: "HEAD", cache: "default",
signal: AbortSignal.any([signal, AbortSignal.timeout(3_000)]),
}).catch(async (error) => {
signal.throwIfAborted();
if (target.search) throw error;
const { createS3Client, toDbS3UploadConfig } = await import("./service");
const storage = toDbS3UploadConfig(config);
if (!storage) throw error;
const key = decodeURIComponent(target.pathname.slice(base.pathname.replace(/\/$/, "").length + 1));
if (!key) throw error;
const client = createS3Client(storage);
try {
const object = await client.send(new HeadObjectCommand({ Bucket: storage.bucket, Key: key }), {
abortSignal: AbortSignal.any([signal, AbortSignal.timeout(5_000)]),
});
return new Response(null, { headers: {
"content-type": object.ContentType || "",
"content-length": String(object.ContentLength ?? 0),
} });
} catch (storageError) {
const status = (storageError as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
if (status === 403 || status === 404) throw new Error(`HTTP ${status}`);
throw error;
} finally {
client.destroy();
}
method: "HEAD", cache: "no-store",
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const contentType = response.headers.get("content-type") || "";

View File

@ -3,7 +3,6 @@ import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSA
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { put } from "@vercel/blob";
import { createHmac } from "node:crypto";
import {
getDefaultStorageConfig,
getStorageConfigById,
@ -240,48 +239,12 @@ export async function maybeEnqueueStorageMigrationTask(params: {
await maybeEnqueueAliyunMigrationTask(params);
}
async function uploadQiniuForm(
config: S3UploadConfig,
key: string,
buffer: ArrayBuffer,
contentType: string
) {
const encode = (value: Buffer) =>
value.toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
const policy = encode(Buffer.from(JSON.stringify({
scope: `${config.bucket}:${key}`,
deadline: Math.floor(Date.now() / 1000) + 120,
insertOnly: 1,
})));
const signature = encode(createHmac("sha1", config.secretAccessKey).update(policy).digest());
const form = new FormData();
form.append("token", `${config.accessKeyId}:${signature}:${policy}`);
form.append("key", key);
form.append("file", new Blob([buffer], { type: contentType }), key.split("/").at(-1));
const startedAt = Date.now();
const response = await fetch("https://upload.qiniup.com/", {
method: "POST",
body: form,
redirect: "error",
signal: AbortSignal.timeout(45_000),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Qiniu form upload returned HTTP ${response.status}`);
}
const result = await response.json() as { key?: string };
if (result.key !== key) throw new Error("Qiniu form upload returned an unexpected key");
logger.info("qiniu_form_upload_completed", { bytes: buffer.byteLength, elapsedMs: Date.now() - startedAt });
}
// S3/MinIO 上传函数(使用 AWS SDK)
async function uploadToS3(
filename: string,
buffer: ArrayBuffer,
config: S3UploadConfig,
s3Client = createS3Client(config),
preferQiniuForm = false
s3Client = createS3Client(config)
) {
const {
endpoint,
@ -297,14 +260,6 @@ async function uploadToS3(
const contentType = getContentType(filename);
try {
if (preferQiniuForm && config.provider === "qiniu-kodo" && config.region === "cn-east-1") {
try {
await uploadQiniuForm(config, key, buffer, contentType);
return { url: buildS3PublicUrl(config, key), pathname: key, contentType };
} catch (error) {
logger.warn("[Upload] Qiniu form upload failed, retrying the same object through S3", error);
}
}
logger.debug(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${bucket}/${key}`);
// 上传文件
@ -596,8 +551,7 @@ async function uploadWithAnyEnabledStorageConfig(
// 上传文件到存储服务
export async function uploadFile(
filename: string,
buffer: ArrayBuffer,
options?: { preferQiniuForm?: boolean }
buffer: ArrayBuffer
): Promise<UploadedFileResult> {
logger.debug(`[Upload] Starting upload for file: ${filename}`);
@ -618,7 +572,7 @@ export async function uploadFile(
logger.debug(`[Upload] Using database S3 config: ${selectedStorageConfig?.name}`);
logger.debug(`[Upload] Endpoint: ${dbS3Config.endpoint}`);
logger.debug(`[Upload] Bucket: ${dbS3Config.bucket}`);
const uploaded = await uploadToS3(filename, buffer, dbS3Config, undefined, options?.preferQiniuForm);
const uploaded = await uploadToS3(filename, buffer, dbS3Config);
lastKnownGoodDbS3Config = dbS3Config;
await maybeEnqueueAliyunMigrationTask({
uploaded,

View File

@ -72,85 +72,6 @@ function projectRoute() {
return { route: load("app/api/agent-projects/route.ts"), projects, setUser: (value: string | null) => { userId = value; } };
}
test("a saved full detail set is queued, while overflow and changed references have distinct errors", async () => {
const references = Array.from({ length: 65 }, (_, index) => ({
id: `detail-${index + 1}`,
imageUrl: `https://example.test/detail-${index + 1}.webp`,
}));
const project = {
id, userId: owner, title: "全套详情", workspaceType: "agent",
canvasData: { agentProjectState: { workflowState: {
agentModeState: { detailReplacement: { references } },
} } },
};
const canvasProject = { id: "id", userId: "userId" };
const created: Array<{ id: string; request: { references: typeof references }; status: string }> = [];
const queued: string[] = [];
const db = {
select: () => ({ from: (table: unknown) => ({
where: () => ({
limit: async () => table === canvasProject ? [project] : [],
orderBy: () => ({ limit: async () => [] }),
}),
}) }),
insert: () => ({ values: () => ({ onConflictDoNothing: async () => {} }) }),
};
const route = loadRuntime({
"@/app/(auth)/auth": { auth: async () => ({ user: { id: owner } }) },
"@/lib/db": { db },
"@/lib/db/schema": { canvasProject, canvasImageTask: { request: "request" }, message: { id: "id" } },
"drizzle-orm": { eq: () => null, and: () => null, inArray: () => null, desc: () => null, sql: () => null },
"@/lib/canvas/image-task-queue": {
createCanvasImageTask: async ({ taskId, request }: { taskId: string; request: { references: typeof references } }) => {
const task = { id: taskId, request, status: "pending" };
created.push(task);
return task;
},
getCanvasImageTaskForUser: async ({ taskId }: { taskId: string }) => created.find((task) => task.id === taskId),
enqueueCanvasImageTask: async (taskId: string) => { queued.push(taskId); },
},
"@/lib/db/queries": {
getEnabledAiModelsWithProvider: async () => [{ model: { id: "fixture-model" } }],
checkUserImageQuota: async () => ({ allowed: true, limit: null }),
getPointsSystemConfig: async () => ({ enabled: false }),
getChatById: async () => ({ userId: owner }),
},
"@/app/(chat)/api/chat/_lib/detail-replacement": { resolveDirectReplacementModelRef: async () => "fixture-model" },
"@/app/(chat)/api/chat/_lib/agent-scene-workflow": {
buildAgentSlotPrompt: ({ workflowPrompt }: { workflowPrompt: string }) => workflowPrompt,
normalizeAgentSupplementPrompt: () => "",
},
})("app/api/agents/detail-replacement/batches/route.ts");
const post = (items: typeof references, replacementStrategy = "product_only", expectedModelId = "fixture-model") => route.POST(
new Request("http://localhost/api/agents/detail-replacement/batches", {
method: "POST",
body: JSON.stringify({
projectId: id, requestId: randomUUID(), references: items, replacementStrategy, expectedModelId,
prompt: "沿用品牌场景", productImageUrls: ["https://example.test/product.webp"],
}),
}),
);
for (const [count, strategy] of [[48, "product_only"], [64, "scene_replace"]] as const) {
const expectedIds = references.slice(0, count).map((ref) => ref.id);
const response = await post(references.slice(0, count), strategy);
expect(response.status).toBe(200);
expect((await response.json()).task.requestedCardIds).toEqual(expectedIds);
expect(created.at(-1)?.request.references.map((ref) => ref.id)).toEqual(expectedIds);
}
const overflow = await post(references);
expect(overflow.status).toBe(400);
expect((await overflow.json()).error).toContain("一次最多替换 64 张");
const changed = await post([{ ...references[0], imageUrl: "https://example.test/changed.webp" }]);
expect(changed.status).toBe(400);
expect((await changed.json()).error).toContain("尚未保存或已发生变化");
const changedModel = await post(references.slice(0, 48), "scene_replace", "outdated-model");
expect(changedModel.status).toBe(409);
expect((await changedModel.json()).code).toBe("DETAIL_REPLACEMENT_MODEL_CHANGED");
expect(queued).toEqual(created.map((task) => task.id));
expect(queued).toHaveLength(2);
});
test("duplicate POSTs return one project without overwriting saved state", async () => {
const { route, projects } = projectRoute();
const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) }));

View File

@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test";
import sharp from "sharp";
import { resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
import { composeOutpaint, getOutpaintGeometry } from "@/lib/ai/extend/fal-outpaint";
import { composeFiboExpansion, getFiboGeometry } from "@/lib/ai/extend/fibo";
test.describe("image extension provider configuration", () => {
test("uses the most recently updated Alibaba image provider key", () => {
@ -35,24 +35,22 @@ test.describe("image extension provider configuration", () => {
});
test("outpainting restores every original pixel in place and keeps the generated border", async () => {
const geometry = getOutpaintGeometry(13, 9, 1.5, 1.5);
const { paddedWidth, paddedHeight, left, top } = geometry;
const geometry = getFiboGeometry(13, 9, 1.5, 1.5);
const { targetWidth, targetHeight, left, top } = geometry;
const pixel = Buffer.from([42, 93, 190, 255, 30, 80, 20, 75]);
const raw = Buffer.concat(Array.from({ length: 59 }, () => pixel)).subarray(0, 13 * 9 * 4);
const source = await sharp(raw, { raw: { width: 13, height: 9, channels: 4 } }).png().toBuffer();
const expanded = await sharp({ create: { width: paddedWidth, height: paddedHeight, channels: 3, background: "#d8bc9e" } }).png().toBuffer();
const composed = await composeOutpaint(source, expanded, geometry);
const expanded = await sharp({ create: { width: targetWidth, height: targetHeight, channels: 3, background: "#d8bc9e" } }).png().toBuffer();
const composed = await composeFiboExpansion(source, expanded, geometry);
const area = await sharp(composed).extract({ left, top, width: 13, height: 9 }).raw().toBuffer();
expect(area.equals(raw)).toBe(true);
expect([...await sharp(composed).extract({ left: 0, top: 0, width: 1, height: 1 }).raw().toBuffer()]).toEqual([216, 188, 158, 255]);
await expect(composeOutpaint(source, source, geometry)).rejects.toThrow("尺寸不正确");
await expect(composeFiboExpansion(source, source, geometry)).rejects.toThrow("尺寸不正确");
});
test("a 4K source keeps its target output size while the model works within its limit", () => {
const g = getOutpaintGeometry(4096, 4096, 1.5, 1.5);
const g = getFiboGeometry(4096, 4096, 1.5, 1.5);
expect([g.targetWidth, g.targetHeight, g.left, g.top]).toEqual([6144, 6144, 1024, 1024]);
expect([g.paddedWidth, g.paddedHeight]).toEqual([2048, 2048]);
expect(g.expandLeft + g.sourceWidth + g.expandRight).toBe(g.paddedWidth);
expect(g.expandTop + g.sourceHeight + g.expandBottom).toBe(g.paddedHeight);
expect(Math.abs(g.expandLeft - g.expandRight)).toBeLessThanOrEqual(1);
expect(g.modelWidth * g.modelHeight).toBeLessThan(25_000_000);
expect(g.modelLeft * 2 + g.sourceWidth).toBeCloseTo(g.modelWidth, -1);
});

View File

@ -7,177 +7,6 @@ import { serverLoader } from "../helpers/canvas-download-server-loader";
import { NextResponse } from "next/server";
test.describe("Qiniu image delivery", () => {
test("coalesces nearby configured CDN preview sizes and quality while preserving original URLs", () => {
const source = "https://cdn.example/uploads/sample.png";
const { buildImageProxyUrl: build } = serverLoader({}, {
NEXT_PUBLIC_CANVAS_CDN_ORIGIN: "https://cdn.example",
})("lib/image-delivery.ts");
expect(build(source, { width: 329, quality: 86, format: "webp" })).toBe(
`${source}?imageView2/2/w/360/format/webp/q/88`,
);
expect(build(source, { width: 331, quality: 92, format: "webp" })).toBe(
build(source, { width: 360, quality: 88, format: "webp" }),
);
expect(build(source, { width: 640, quality: 76, format: "webp" })).toContain("w/640/format/webp/q/76");
expect(build(source, { width: 960, quality: 92, format: "webp" })).toContain("w/960/format/webp/q/88");
expect(build(`${source}?token=signed`, { width: 331, format: "webp" })).toBe(`${source}?token=signed`);
expect(build(source)).toBe(source);
expect(new URL(build(source, { delivery: "original" }), "https://app.example").searchParams.get("url")).toBe(source);
expect(build("https://yybb.top/uploads/sample.png", { width: 331, quality: 92, format: "webp" })).toContain("w/331/format/webp/q/92");
});
for (const mode of ["cdn", "storage", "signed"] as const) {
test(`98K reference staging reads complete image bytes via ${mode}`, async () => {
const original = await sharp({ create: { width: 64, height: 64, channels: 3, background: "#a45623" } }).png().toBuffer();
const cdn = "https://cdn.example";
const source = `${cdn}/media/uploads/reference.png${mode === "signed" ? "?token=signed" : ""}`;
const calls: string[] = [];
const command = (operation: string) => class {
operation = operation;
constructor(public input: { Key: string; Body?: Buffer }) {}
};
const config = { provider: "qiniu-kodo", bucket: "images", publicUrl: `${cdn}/media` };
const load = serverLoader({
"./vip98k-chat": {},
"@/lib/db/queries": { getDefaultStorageConfig: async () => config, getSystemSetting: async () => null },
"@/lib/storage/image-archive": {},
"@aws-sdk/client-s3": {
GetObjectCommand: command("get"), HeadObjectCommand: command("head"), PutObjectCommand: command("put"),
},
"@/lib/storage/service": {
toDbS3UploadConfig: () => config,
buildS3PublicUrl: (_: unknown, key: string) => `${config.publicUrl}/${key}`,
createS3Client: () => ({ send: async ({ operation, input }: { operation: string; input: { Key: string; Body: Buffer } }) => {
calls.push(operation);
if (operation === "head") return {};
if (operation === "get") {
expect(input.Key).toBe("uploads/reference.png");
return { Body: { transformToByteArray: async () => original }, ContentLength: original.length };
}
expect((await sharp(input.Body).metadata()).format).toBe("jpeg");
return {};
} }),
},
"@/lib/observability/logger": { createLogger: () => ({ debug() {}, info() {}, warn() {}, error() {} }) },
fetch: async (url: string, init: RequestInit) => {
if (url === source) {
calls.push("cdn");
expect(init.redirect).toBe("error");
return mode === "cdn"
? new Response(original, { headers: { "content-type": "image/png", "content-length": String(original.length) } })
: new Response(original.subarray(0, 4), { headers: { "content-type": "image/png", "content-length": String(original.length) } });
}
calls.push("submit");
expect(String(init.body)).toContain("vip98k-cache-v2");
return Response.json({ taskId: "test-task", task_id: "test-task" });
},
}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: cdn });
const { submitVip98kExternalTask } = load("lib/ai/providers/vip98k.ts");
await submitVip98kExternalTask({ apiKey: "test", modelId: "google/nano-banana-2", kind: "image", imageUrls: [source], durable: true });
expect(calls).toEqual(["head", ...(mode !== "signed" ? ["cdn"] : []), ...(mode !== "cdn" ? ["get"] : []), "put", "submit"]);
});
}
for (const [source, reusesSource] of [
["https://cdn.example/media/uploads/source.png", true],
["https://cdn.example/media/uploads/direct/1a868070-0d5b-4c99-937d-f10d6dc59612/source.png", true],
["https://cdn.example/media/uploads/source.png?token=temporary", false],
["https://cdn.example/media-other/uploads/source.png", false],
] as const) {
test(`watermark removal handles ${source} according to its storage origin and signature`, async () => {
const storedResult = "https://cdn.example/media/uploads/result.png";
const uploadedInput = "https://cdn.example/media/uploads/staged.png";
const transferred: string[] = [];
const uploaded: string[] = [];
const saved: string[] = [];
const route = serverLoader({
"next/server": { NextResponse: Response },
"@/app/(auth)/auth": { auth: async () => ({ user: { id: "user-1" } }) },
"@/lib/db": { db: {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ value: { apiKey: "fixture" } }] }) }) }),
insert: () => ({ values: (row: { originalUrl: string }) => ({
returning: async () => { saved.push(row.originalUrl); return [{ id: "image-1" }]; },
}) }),
} },
"@/lib/db/schema": { systemSettings: {}, generatedImage: {} },
"drizzle-orm": { eq: () => null },
"@/lib/db/queries": {
getDefaultStorageConfig: async () => ({ type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example/media" }),
getPointsSystemConfig: async () => ({}),
getMediaAssetsByUserAndUrls: async () => [],
upsertMediaAsset: async () => null,
},
"@/lib/storage/service": {
uploadFile: async (filename: string) => { uploaded.push(filename); return { url: uploadedInput }; },
},
"@/lib/points/feature-charge": { prepareFeaturePointsCharge: async () => ({ ok: true, chargeable: false }) },
"@/lib/points/config": { getImageToolCreditCost: () => 0 },
"@/lib/canvas/generate-image": {},
fetch: async (url: string, init?: RequestInit) => {
if (new URL(url).hostname === "techsz.aoscdn.com") {
if (init?.method === "POST") {
expect((init.body as FormData).get("url")).toBe(reusesSource ? source : uploadedInput);
return Response.json({ data: { task_id: "fixture-task" } });
}
return Response.json({ data: { state: 1, file: storedResult } });
}
transferred.push(url);
return new Response(new Uint8Array([1, 2, 3]));
},
})("app/api/watermark-remove/route.ts");
const response = await route.POST(new Request("https://app.example/api/watermark-remove", {
method: "POST",
body: JSON.stringify({ image_url: source }),
}));
expect(await response.json()).toMatchObject({ success: true, original_url: storedResult });
expect(transferred).toEqual(reusesSource ? [] : [source]);
expect(uploaded).toHaveLength(reusesSource ? 0 : 1);
expect(saved).toEqual([storedResult]);
});
}
for (const formWorks of [true, false]) {
test(`local edit storage ${formWorks ? "uses Qiniu form upload" : "reuses the same key on S3 fallback"}`, async () => {
const bytes = new Uint8Array([1, 2, 3]).buffer;
const config = {
id: "fixture", type: "qiniu-kodo", isEnabled: true, region: "cn-east-1",
endpoint: "https://s3.example", publicUrl: "https://cdn.example",
bucket: "images", accessKeyId: "fixture-ak", secretAccessKey: "fixture-sk",
};
let formKey = "";
const s3Keys: string[] = [];
const storage = serverLoader({
"@vercel/blob": {},
"@aws-sdk/client-s3": {
PutObjectCommand: class { constructor(public input: { Key: string }) {} },
S3Client: class {
async send(command: { input: { Key: string } }) { s3Keys.push(command.input.Key); }
},
},
"@/lib/db/queries": {
getDefaultStorageConfig: async () => config,
getSystemSetting: async () => ({ value: { enabled: false } }),
},
"@/lib/storage/migration-db": {},
"@/lib/observability/logger": { createLogger: () => ({ debug() {}, warn() {}, info() {}, error() {} }) },
fetch: async (url: string, init: RequestInit) => {
expect(url).toBe("https://upload.qiniup.com/");
const form = init.body as FormData;
formKey = String(form.get("key"));
const token = String(form.get("token"));
const policy = JSON.parse(Buffer.from(token.split(":")[2], "base64url").toString());
expect(policy).toMatchObject({ scope: `images:${formKey}`, insertOnly: 1 });
expect((form.get("file") as File).size).toBe(3);
return formWorks ? Response.json({ key: formKey }) : new Response(null, { status: 503 });
},
})("lib/storage/service.ts");
const saved = await storage.uploadFile("local-edit.png", bytes, { preferQiniuForm: true });
expect(saved).toMatchObject({ pathname: formKey, url: "https://cdn.example/" + formKey });
expect(s3Keys).toEqual(formWorks ? [] : [formKey]);
});
}
test("model result buckets use our thumbnail endpoint without forbidden OSS transforms", () => {
const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/output.png";
const preview = new URL(buildImageProxyUrl(source, { width: 960, format: "webp" }), "https://app.example");

View File

@ -10,8 +10,7 @@ test("original downloads use the archived JPEG rather than the prepared PNG and
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl: "https://cdn.example/original.jpg", metadata: {
downloadAsset: { status: "ready", url: "https://cdn.example/converted.png", contentType: "image/png" },
} }) },
fetch: async (url: URL, options: { method: string; cache: string }) => {
expect(options.cache).toBe("default");
fetch: async (url: URL, options: { method: string }) => {
calls.push({ url: url.toString(), method: options.method });
return { ok: true, headers: new Headers({ "content-type": "image/jpeg", "content-length": "12345" }),
arrayBuffer() { throw new Error("Image bytes must bypass the application"); } };
@ -53,28 +52,6 @@ test("temporary third-party URLs are never checked with HEAD when no archive is
expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull();
});
test("a CDN metadata timeout falls back to the original Kodo object without reading its body", async () => {
let closed = false;
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": {},
"./service": {
toDbS3UploadConfig: () => ({ bucket: "images" }),
createS3Client: () => ({
send: async (command: { input: { Bucket: string; Key: string } }) => {
expect(command.input).toEqual({ Bucket: "images", Key: "uploads/source.png" });
return { ContentType: "image/jpeg", ContentLength: 12345 };
},
destroy: () => { closed = true; },
}),
},
fetch: async () => { throw new DOMException("Timed out", "TimeoutError"); },
})("lib/storage/original-image-download.ts");
const result = await module.resolveOriginalCdnDownload("https://cdn.example/uploads/source.png", "图片.png", new AbortController().signal);
expect(result).toMatchObject({ delivery: "cdn", filename: "图片.jpg", bytes: 12345 });
expect(closed).toBe(true);
});
test("original download metadata reports missing sources with cause, advice and a feedback ID", async () => {
const route = serverLoader({
"next/server": { NextResponse: Response },
@ -89,30 +66,6 @@ test("original download metadata reports missing sources with cause, advice and
expect(data.error).toContain(data.requestId);
});
test("the original proxy returns the first bytes while the upstream is still sending the file", async () => {
const head = Uint8Array.from([255, 216, 255, 224]);
const tail = Uint8Array.from([4, 8, 255, 217]);
let upstream!: ReadableStreamDefaultController<Uint8Array>;
const body = new ReadableStream<Uint8Array>({
start(controller) { upstream = controller; controller.enqueue(head); },
});
const route = serverLoader({
"next/server": { NextResponse: Response },
fetch: async () => new Response(body, { headers: { "content-type": "image/jpeg", "content-length": "8" } }),
})("app/api/image-proxy/route.ts");
const response = await route.GET({
nextUrl: new URL("https://app.example/api/image-proxy?url=https%3A%2F%2Fimages.example%2Foriginal.jpg&original=1"),
signal: new AbortController().signal, headers: new Headers(),
});
expect(response.headers.get("content-length")).toBe("8");
const reader = response.body.getReader();
expect((await reader.read()).value).toEqual(head);
upstream.enqueue(tail);
upstream.close();
expect((await reader.read()).value).toEqual(tail);
expect((await reader.read()).done).toBe(true);
});
function browserMocks() {
const frames: any[] = []; const links: any[] = [];
return { frames, links,