Compare commits
No commits in common. "main" and "codex/stylization-load-20261110" have entirely different histories.
main
...
codex/styl
@ -4,8 +4,7 @@ import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
|
|||||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||||
import {
|
import {
|
||||||
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
|
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
|
||||||
findConfiguredDetailReplacementImageModel,
|
normalizeDetailReplacementImageModelConfig,
|
||||||
isDetailReplacementImageModel,
|
|
||||||
} from "@/lib/ai/detail-replacement-image-model-config";
|
} from "@/lib/ai/detail-replacement-image-model-config";
|
||||||
import {
|
import {
|
||||||
compositeReplacementWithReferenceLocks,
|
compositeReplacementWithReferenceLocks,
|
||||||
@ -30,6 +29,7 @@ import {
|
|||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { bufferToArrayBuffer } from "./persistence";
|
import { bufferToArrayBuffer } from "./persistence";
|
||||||
|
import { isVideoProviderType } from "./video-models";
|
||||||
import type { AgentPresetMessageImagePart } from "./agent-scene-workflow";
|
import type { AgentPresetMessageImagePart } from "./agent-scene-workflow";
|
||||||
import type { PostRequestBody } from "../schema";
|
import type { PostRequestBody } from "../schema";
|
||||||
|
|
||||||
@ -2161,10 +2161,22 @@ export async function resolveDirectReplacementModelRef(params: {
|
|||||||
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
|
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
|
||||||
}) {
|
}) {
|
||||||
const models = params.models || (await getEnabledAiModelsWithProvider(false));
|
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({
|
const configuredModelSetting = await getSystemSetting({
|
||||||
key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
|
key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
|
||||||
}).catch(() => null);
|
}).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) {
|
if (configuredModel) {
|
||||||
return configuredModel.model.id;
|
return configuredModel.model.id;
|
||||||
}
|
}
|
||||||
@ -2178,7 +2190,6 @@ export async function resolveDirectReplacementModelRef(params: {
|
|||||||
return routedModel;
|
return routedModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageModels = models.filter(isDetailReplacementImageModel);
|
|
||||||
const preferred =
|
const preferred =
|
||||||
imageModels.find(({ model }) => model.isDefault) ||
|
imageModels.find(({ model }) => model.isDefault) ||
|
||||||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||
|
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||
|
||||||
|
|||||||
@ -10,10 +10,6 @@ import {
|
|||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
|
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
|
||||||
import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability";
|
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 {
|
import {
|
||||||
isImageGenerationModelDescriptor,
|
isImageGenerationModelDescriptor,
|
||||||
isVideoModelDescriptor,
|
isVideoModelDescriptor,
|
||||||
@ -161,17 +157,10 @@ export async function GET(request: Request) {
|
|||||||
: await getEnabledAiModelsWithProvider(false, true);
|
: await getEnabledAiModelsWithProvider(false, true);
|
||||||
const agentModelWithProvider = await getAgentAiModelWithProvider();
|
const agentModelWithProvider = await getAgentAiModelWithProvider();
|
||||||
const defaultModel = await getDefaultAiModel();
|
const defaultModel = await getDefaultAiModel();
|
||||||
const [siteSettings, agentWorkflowUiConfigSetting, detailReplacementModelSetting] = await Promise.all([
|
const [siteSettings, agentWorkflowUiConfigSetting] = await Promise.all([
|
||||||
getSiteSettings(),
|
getSiteSettings(),
|
||||||
getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY }).catch(() => null),
|
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];
|
type ModelWithProvider = (typeof modelsWithProvider)[number];
|
||||||
const serializeModel = ({ model, provider }: ModelWithProvider) => ({
|
const serializeModel = ({ model, provider }: ModelWithProvider) => ({
|
||||||
id: model.id,
|
id: model.id,
|
||||||
@ -320,7 +309,6 @@ export async function GET(request: Request) {
|
|||||||
: null,
|
: null,
|
||||||
agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null,
|
agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null,
|
||||||
agentWorkflowConfiguredModels,
|
agentWorkflowConfiguredModels,
|
||||||
detailReplacementModel: detailReplacementModel ? serializeModel(detailReplacementModel) : null,
|
|
||||||
agentModel: agentModelWithProvider
|
agentModel: agentModelWithProvider
|
||||||
? {
|
? {
|
||||||
id: agentModelWithProvider.model.id,
|
id: agentModelWithProvider.model.id,
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { auth } from "@/app/(auth)/auth";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
|
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
|
||||||
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
|
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
|
||||||
import { MAX_DETAIL_REPLACEMENT_REFERENCES } from "@/lib/agents/mode-workspace-state";
|
|
||||||
import {
|
import {
|
||||||
createCanvasImageTask,
|
createCanvasImageTask,
|
||||||
enqueueCanvasImageTask,
|
enqueueCanvasImageTask,
|
||||||
@ -147,14 +146,6 @@ export async function POST(request: Request) {
|
|||||||
.map((item) => [String(item.id), String(item.imageUrl)]),
|
.map((item) => [String(item.id), String(item.imageUrl)]),
|
||||||
);
|
);
|
||||||
const requested = Array.isArray(body.references) ? body.references : [];
|
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 references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
|
||||||
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
|
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
|
||||||
return {
|
return {
|
||||||
@ -169,6 +160,7 @@ export async function POST(request: Request) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
|
references.length === 0 || references.length > 40 ||
|
||||||
new Set(references.map((item) => item.id)).size !== references.length ||
|
new Set(references.map((item) => item.id)).size !== references.length ||
|
||||||
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
|
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)) {
|
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
|
||||||
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
|
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);
|
const quota = await checkUserImageQuota(session.user.id);
|
||||||
if (!quota.allowed) {
|
if (!quota.allowed) {
|
||||||
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });
|
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });
|
||||||
|
|||||||
@ -2294,9 +2294,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
loadedAgentProjectId === agentProjectId;
|
loadedAgentProjectId === agentProjectId;
|
||||||
const [workflowTemplateMarker, setWorkflowTemplateMarker] = useState<HTMLDivElement | null>(null);
|
const [workflowTemplateMarker, setWorkflowTemplateMarker] = useState<HTMLDivElement | null>(null);
|
||||||
const [workflowTemplatesRequested, setWorkflowTemplatesRequested] = useState(false);
|
const [workflowTemplatesRequested, setWorkflowTemplatesRequested] = useState(false);
|
||||||
const hasWorkflowBase = Boolean(siteSettingsData?.agentWorkflowUiConfig);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAgentMode || !hasActiveAgentProject || !hasWorkflowBase || workflowTemplatesRequested) return;
|
if (!isAgentMode || !hasActiveAgentProject || workflowTemplatesRequested) return;
|
||||||
const element = workflowTemplateMarker;
|
const element = workflowTemplateMarker;
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
const observer = new IntersectionObserver(([entry]) => {
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
@ -2304,11 +2303,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
});
|
});
|
||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [hasActiveAgentProject, hasWorkflowBase, isAgentMode, workflowTemplateMarker, workflowTemplatesRequested]);
|
}, [hasActiveAgentProject, isAgentMode, workflowTemplateMarker, workflowTemplatesRequested]);
|
||||||
const { data: workflowTemplates, error: workflowTemplatesError, isValidating: workflowTemplatesLoading, mutate: reloadWorkflowTemplates } = useSWR<{
|
const { data: workflowTemplates, error: workflowTemplatesError, isValidating: workflowTemplatesLoading, mutate: reloadWorkflowTemplates } = useSWR<{
|
||||||
steps: AgentWorkflowUiConfig["steps"];
|
steps: AgentWorkflowUiConfig["steps"];
|
||||||
}>(
|
}>(
|
||||||
isAgentMode && hasActiveAgentProject && hasWorkflowBase && workflowTemplatesRequested
|
isAgentMode && hasActiveAgentProject && siteSettingsData?.agentWorkflowUiConfig && workflowTemplatesRequested
|
||||||
? "/api/site-settings?scope=agent-workflow-templates"
|
? "/api/site-settings?scope=agent-workflow-templates"
|
||||||
: null,
|
: null,
|
||||||
fetchCanvasSiteSettings,
|
fetchCanvasSiteSettings,
|
||||||
|
|||||||
@ -159,7 +159,6 @@ import type {
|
|||||||
} from "@/types/agent";
|
} from "@/types/agent";
|
||||||
import {
|
import {
|
||||||
createEmptyAgentModeWorkspaceState,
|
createEmptyAgentModeWorkspaceState,
|
||||||
MAX_DETAIL_REPLACEMENT_REFERENCES,
|
|
||||||
} from "@/lib/agents/mode-workspace-state";
|
} from "@/lib/agents/mode-workspace-state";
|
||||||
import {
|
import {
|
||||||
createEmptyAgentGenerationProjectWorkspaceState,
|
createEmptyAgentGenerationProjectWorkspaceState,
|
||||||
@ -214,7 +213,6 @@ export interface AgentGenerationWorkspaceDarkV2Props {
|
|||||||
interface AgentWorkflowModelOption {
|
interface AgentWorkflowModelOption {
|
||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
providerName?: string;
|
|
||||||
providerType: string;
|
providerType: string;
|
||||||
supportsImageGen?: boolean;
|
supportsImageGen?: boolean;
|
||||||
isMultimodal?: boolean;
|
isMultimodal?: boolean;
|
||||||
@ -2645,9 +2643,7 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
|
|||||||
? record.requestedCardIds
|
? record.requestedCardIds
|
||||||
.map((entry) => String(entry || "").trim())
|
.map((entry) => String(entry || "").trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, record.modeType === "detail_replacement"
|
.slice(0, MAX_AGENT_MODE_REFERENCE_COUNT)
|
||||||
? MAX_DETAIL_REPLACEMENT_REFERENCES
|
|
||||||
: MAX_AGENT_MODE_REFERENCE_COUNT)
|
|
||||||
: null,
|
: null,
|
||||||
} satisfies LocalGenerationPendingState;
|
} satisfies LocalGenerationPendingState;
|
||||||
});
|
});
|
||||||
@ -3388,13 +3384,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: modelsData, error: modelsError, mutate: refreshModels } = useSWR<{
|
const { data: modelsData } = useSWR<{
|
||||||
models: AgentWorkflowModelOption[];
|
models: AgentWorkflowModelOption[];
|
||||||
defaultModelId?: string | null;
|
defaultModelId?: string | null;
|
||||||
agentWorkflowConfiguredDefaultModelId?: string | null;
|
agentWorkflowConfiguredDefaultModelId?: string | null;
|
||||||
agentWorkflowDefaultModelId?: string | null;
|
agentWorkflowDefaultModelId?: string | null;
|
||||||
agentWorkflowConfiguredModels?: AgentWorkflowModelOption[];
|
agentWorkflowConfiguredModels?: AgentWorkflowModelOption[];
|
||||||
detailReplacementModel?: AgentWorkflowModelOption | null;
|
|
||||||
agentModel?: any;
|
agentModel?: any;
|
||||||
}>("/api/models", fetcher);
|
}>("/api/models", fetcher);
|
||||||
|
|
||||||
@ -3440,6 +3435,17 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const activeBusinessModeDefaultModelId = !requiresPresetSelection
|
const activeBusinessModeDefaultModelId = !requiresPresetSelection
|
||||||
? String(activeBusinessMode?.defaultModelId || "").trim() || null
|
? String(activeBusinessMode?.defaultModelId || "").trim() || null
|
||||||
: 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 =
|
const presetDrivenResultModeType: AgentGenerationResultModeType =
|
||||||
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement"
|
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement"
|
||||||
? detailReplacementMethod === "ai_detail_generate"
|
? detailReplacementMethod === "ai_detail_generate"
|
||||||
@ -3455,19 +3461,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection
|
const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection
|
||||||
? presetDrivenResultModeType
|
? presetDrivenResultModeType
|
||||||
: businessModeResultModeType;
|
: 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 isSceneGenerationPreset = currentResultModeType === "scene_generation";
|
||||||
const isDetailReplacementPreset =
|
const isDetailReplacementPreset =
|
||||||
currentResultModeType === "detail_replacement" ||
|
currentResultModeType === "detail_replacement" ||
|
||||||
@ -4119,15 +4112,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
);
|
);
|
||||||
const executableModelOptions = useMemo(() => {
|
const executableModelOptions = useMemo(() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const workflowModels = detailReplacementModel
|
return [...selectableModels, ...configuredWorkflowModels].filter((model) => {
|
||||||
? [detailReplacementModel, ...configuredWorkflowModels]
|
|
||||||
: configuredWorkflowModels;
|
|
||||||
return [...selectableModels, ...workflowModels].filter((model) => {
|
|
||||||
if (seen.has(model.id)) return false;
|
if (seen.has(model.id)) return false;
|
||||||
seen.add(model.id);
|
seen.add(model.id);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [configuredWorkflowModels, detailReplacementModel, selectableModels]);
|
}, [configuredWorkflowModels, selectableModels]);
|
||||||
const executableModelIdSet = useMemo(
|
const executableModelIdSet = useMemo(
|
||||||
() => new Set(executableModelOptions.map((model) => model.id)),
|
() => new Set(executableModelOptions.map((model) => model.id)),
|
||||||
[executableModelOptions]
|
[executableModelOptions]
|
||||||
@ -4146,9 +4136,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
selectableModels[0],
|
selectableModels[0],
|
||||||
[executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId]
|
[executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId]
|
||||||
);
|
);
|
||||||
const lockedWorkflowModelLabel = detailReplacementModel?.providerName
|
const lockedWorkflowModelLabel =
|
||||||
? `${detailReplacementModel.displayName} 「${detailReplacementModel.providerName}」`
|
lockedWorkflowModel?.displayName ||
|
||||||
: lockedWorkflowModel?.displayName ||
|
|
||||||
selectedAgentPreset?.defaultModel?.displayName ||
|
selectedAgentPreset?.defaultModel?.displayName ||
|
||||||
(lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置");
|
(lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置");
|
||||||
const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("settings");
|
const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("settings");
|
||||||
@ -6327,15 +6316,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`);
|
toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isDetailReplacementModelLoading) {
|
|
||||||
if (modelsError) {
|
|
||||||
toast.error("详情替换模型加载失败,正在重新读取");
|
|
||||||
void refreshModels().catch(() => {});
|
|
||||||
} else {
|
|
||||||
toast.info("正在读取详情替换模型,请稍候");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) {
|
if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) {
|
||||||
toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
|
toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
|
||||||
return;
|
return;
|
||||||
@ -6403,10 +6383,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}
|
}
|
||||||
if (replacementModeType === "detail_replacement") {
|
if (replacementModeType === "detail_replacement") {
|
||||||
if (detailBatchSubmittingRef.current) return;
|
if (detailBatchSubmittingRef.current) return;
|
||||||
if (replacementExecutionCards.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
|
|
||||||
toast.error(`本次选中了 ${replacementExecutionCards.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isValidChatUuid(projectId)) {
|
if (!isValidChatUuid(projectId)) {
|
||||||
toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换");
|
toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换");
|
||||||
return;
|
return;
|
||||||
@ -6427,14 +6403,13 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const saved = await onPersistProject?.();
|
const saved = await onPersistProject?.();
|
||||||
if (saved === false) throw new Error("项目尚未保存,请重试");
|
if (saved === false) throw new Error("项目尚未保存,请重试");
|
||||||
const requestId = generateUUID();
|
const requestId = generateUUID();
|
||||||
const response = await fetch("/api/agents/detail-replacement/batches", {
|
const response = await fetchWithErrorHandlers("/api/agents/detail-replacement/batches", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
projectId,
|
projectId,
|
||||||
requestId,
|
requestId,
|
||||||
model: resolvedModelId,
|
model: resolvedModelId,
|
||||||
expectedModelId: resolvedModelId === "auto" ? undefined : resolvedModelId,
|
|
||||||
imageSize: imageParams.imageSize,
|
imageSize: imageParams.imageSize,
|
||||||
replacementStrategy: detailReplacementStrategy,
|
replacementStrategy: detailReplacementStrategy,
|
||||||
prompt: detailReplacementStrategy === "product_only"
|
prompt: detailReplacementStrategy === "product_only"
|
||||||
@ -6458,7 +6433,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (!response.ok || !payload?.task?.id) {
|
if (!response.ok || !payload?.task?.id) {
|
||||||
if (payload?.code === "DETAIL_REPLACEMENT_MODEL_CHANGED") void refreshModels().catch(() => {});
|
|
||||||
throw new Error(payload?.error || "详情替换任务提交失败");
|
throw new Error(payload?.error || "详情替换任务提交失败");
|
||||||
}
|
}
|
||||||
setPendingGeneration({
|
setPendingGeneration({
|
||||||
@ -6646,8 +6620,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
input,
|
input,
|
||||||
isDetailAiGenerationMethod,
|
isDetailAiGenerationMethod,
|
||||||
isDetailReplacementPreset,
|
isDetailReplacementPreset,
|
||||||
isDetailReplacementModelLoading,
|
|
||||||
modelsError,
|
|
||||||
isSelectedBusinessModeUnavailable,
|
isSelectedBusinessModeUnavailable,
|
||||||
isSkuReplacementPreset,
|
isSkuReplacementPreset,
|
||||||
effectiveDefaultModelId,
|
effectiveDefaultModelId,
|
||||||
@ -6675,7 +6647,6 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
onPersistProject,
|
onPersistProject,
|
||||||
projectId,
|
projectId,
|
||||||
refreshDetailBatch,
|
refreshDetailBatch,
|
||||||
refreshModels,
|
|
||||||
]);
|
]);
|
||||||
const handleStopGeneration = useCallback(() => {
|
const handleStopGeneration = useCallback(() => {
|
||||||
detailGenerationCancelRef.current = true;
|
detailGenerationCancelRef.current = true;
|
||||||
@ -9448,11 +9419,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70">
|
<div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70">
|
||||||
{isDetailReplacementModelLoading
|
{isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
|
||||||
? modelsError ? "详情替换模型加载失败,点击批量替换可重试" : "正在读取详情替换模型"
|
|
||||||
: isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
|
|
||||||
<span className="ml-2 font-medium text-white">
|
<span className="ml-2 font-medium text-white">
|
||||||
{isDetailReplacementModelLoading ? null : isWorkflowModelLocked
|
{isWorkflowModelLocked
|
||||||
? lockedWorkflowModelLabel
|
? lockedWorkflowModelLabel
|
||||||
: currentModel?.displayName || "自动选择"}
|
: currentModel?.displayName || "自动选择"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -14,8 +14,6 @@ import {
|
|||||||
sanitizeAgentDetailBrief,
|
sanitizeAgentDetailBrief,
|
||||||
} from "@/lib/agents/detail-generation-config";
|
} from "@/lib/agents/detail-generation-config";
|
||||||
|
|
||||||
export const MAX_DETAIL_REPLACEMENT_REFERENCES = 64;
|
|
||||||
|
|
||||||
export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState {
|
export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState {
|
||||||
return {
|
return {
|
||||||
detailReplacement: {
|
detailReplacement: {
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { isVideoModelDescriptor } from "@/lib/ai/model-classification";
|
|
||||||
|
|
||||||
export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY =
|
export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY =
|
||||||
"detail_replacement_image_model_config";
|
"detail_replacement_image_model_config";
|
||||||
|
|
||||||
@ -22,28 +20,3 @@ export function normalizeDetailReplacementImageModelConfig(
|
|||||||
modelId: modelId || DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID,
|
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,52 +1,37 @@
|
|||||||
import { createFalClient } from "@fal-ai/client";
|
import { createFalClient } from "@fal-ai/client";
|
||||||
import sharp from "sharp";
|
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 targetWidth = Math.round(width * xScale);
|
||||||
const targetHeight = Math.round(height * yScale);
|
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 modelWidth = Math.max(1, Math.floor(targetWidth * ratio));
|
||||||
const modelHeight = Math.max(1, Math.floor(targetHeight * ratio));
|
const modelHeight = Math.max(1, Math.floor(targetHeight * ratio));
|
||||||
const sourceWidth = Math.max(1, Math.round(width * modelWidth / targetWidth));
|
const sourceWidth = Math.max(1, Math.round(width * modelWidth / targetWidth));
|
||||||
const sourceHeight = Math.max(1, Math.round(height * modelHeight / targetHeight));
|
const sourceHeight = Math.max(1, Math.round(height * modelHeight / targetHeight));
|
||||||
const left = Math.floor((targetWidth - width) / 2);
|
const left = Math.floor((targetWidth - width) / 2);
|
||||||
const top = Math.floor((targetHeight - height) / 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 {
|
return {
|
||||||
targetWidth, targetHeight, left, top, modelWidth, modelHeight, sourceWidth, sourceHeight,
|
targetWidth, targetHeight, left, top, modelWidth, modelHeight, sourceWidth, sourceHeight,
|
||||||
paddedWidth: modelWidth + extraWidth,
|
modelLeft: Math.round(left * modelWidth / targetWidth),
|
||||||
paddedHeight: modelHeight + extraHeight,
|
modelTop: Math.round(top * modelHeight / targetHeight),
|
||||||
cropLeft,
|
|
||||||
cropTop,
|
|
||||||
expandLeft: modelLeft + cropLeft,
|
|
||||||
expandRight: modelWidth - sourceWidth - modelLeft + extraWidth - cropLeft,
|
|
||||||
expandTop: modelTop + cropTop,
|
|
||||||
expandBottom: modelHeight - sourceHeight - modelTop + extraHeight - cropTop,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function composeOutpaint(source: Buffer, expanded: Buffer, geometry: ReturnType<typeof getOutpaintGeometry>) {
|
export async function composeFiboExpansion(source: Buffer, expanded: Buffer, geometry: ReturnType<typeof getFiboGeometry>) {
|
||||||
const { targetWidth, targetHeight, left, top, modelWidth, modelHeight, paddedWidth, paddedHeight, cropLeft, cropTop } = geometry;
|
const { targetWidth, targetHeight, left, top, modelWidth, modelHeight } = geometry;
|
||||||
const modelImage = sharp(expanded);
|
const modelImage = sharp(expanded);
|
||||||
const info = await modelImage.metadata();
|
const info = await modelImage.metadata();
|
||||||
if (info.width !== paddedWidth || info.height !== paddedHeight) {
|
if (info.width !== modelWidth || info.height !== modelHeight) {
|
||||||
throw new Error("扩图服务返回的图片尺寸不正确");
|
throw new Error("扩图服务返回的图片尺寸不正确");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy the source rectangle, including transparent pixels, without blending or resizing it.
|
// 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 { data: original, info: sourceInfo } = await sharp(source).toColourspace("srgb").ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||||
const result = await modelImage
|
const result = await modelImage.resize(targetWidth, targetHeight, { fit: "fill" }).toColourspace("srgb").ensureAlpha().raw().toBuffer();
|
||||||
.extract({ left: cropLeft, top: cropTop, width: modelWidth, height: modelHeight })
|
|
||||||
.resize(targetWidth, targetHeight, { fit: "fill" })
|
|
||||||
.toColourspace("srgb").ensureAlpha().raw().toBuffer();
|
|
||||||
const stride = sourceInfo.width * 4;
|
const stride = sourceInfo.width * 4;
|
||||||
for (let row = 0; row < sourceInfo.height; row++) {
|
for (let row = 0; row < sourceInfo.height; row++) {
|
||||||
original.copy(result, ((top + row) * targetWidth + left) * 4, row * stride, (row + 1) * stride);
|
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();
|
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;
|
imageUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
xScale: number;
|
xScale: number;
|
||||||
@ -64,30 +49,26 @@ export async function expandWithFalOutpaint({ imageUrl, apiKey, xScale, yScale }
|
|||||||
if (!response.ok) throw new Error("无法读取待扩展的原图");
|
if (!response.ok) throw new Error("无法读取待扩展的原图");
|
||||||
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
|
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
|
||||||
.rotate().png().toBuffer({ resolveWithObject: true });
|
.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) {
|
if (geometry.targetWidth * geometry.targetHeight > 64_000_000) {
|
||||||
throw new Error("扩图后的尺寸过大,请降低扩展倍数");
|
throw new Error("扩图后的尺寸过大,请降低扩展倍数");
|
||||||
}
|
}
|
||||||
const fal = createFalClient({ credentials: apiKey });
|
const fal = createFalClient({ credentials: apiKey });
|
||||||
const modelSource = geometry.sourceWidth === info.width && geometry.sourceHeight === info.height
|
const image = await fal.storage.upload(new Blob([new Uint8Array(source)], { type: "image/png" }));
|
||||||
? source
|
const { data } = await fal.subscribe(FIBO_EXPAND_MODEL, {
|
||||||
: 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, {
|
|
||||||
input: {
|
input: {
|
||||||
image_url: image,
|
image_url: image,
|
||||||
expand_left: geometry.expandLeft,
|
canvas_size: [geometry.modelWidth, geometry.modelHeight],
|
||||||
expand_right: geometry.expandRight,
|
original_image_size: [geometry.sourceWidth, geometry.sourceHeight],
|
||||||
expand_top: geometry.expandTop,
|
original_image_location: [geometry.modelLeft, geometry.modelTop],
|
||||||
expand_bottom: geometry.expandBottom,
|
retain_original_quality: true,
|
||||||
mode: "high",
|
prompt: FIBO_EXPAND_PROMPT,
|
||||||
output_format: "png",
|
|
||||||
},
|
},
|
||||||
timeout: 90_000,
|
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("扩图服务未返回图片");
|
if (!outputUrl) throw new Error("扩图服务未返回图片");
|
||||||
const output = await fetch(outputUrl, { signal: AbortSignal.timeout(25_000) });
|
const output = await fetch(outputUrl, { signal: AbortSignal.timeout(25_000) });
|
||||||
if (!output.ok) throw new Error("无法读取扩图结果");
|
if (!output.ok) throw new Error("无法读取扩图结果");
|
||||||
return composeOutpaint(source, Buffer.from(await output.arrayBuffer()), geometry);
|
return composeFiboExpansion(source, Buffer.from(await output.arrayBuffer()), geometry);
|
||||||
}
|
}
|
||||||
@ -6,7 +6,7 @@ import {
|
|||||||
upsertMediaAsset,
|
upsertMediaAsset,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import { type ExtendProvider, resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
|
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 { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { generatedImage } from "@/lib/db/schema";
|
import { generatedImage } from "@/lib/db/schema";
|
||||||
@ -536,8 +536,8 @@ export async function executeExtendJob(
|
|||||||
let buffer: Buffer;
|
let buffer: Buffer;
|
||||||
let modelId: string;
|
let modelId: string;
|
||||||
if (config.provider === "fal") {
|
if (config.provider === "fal") {
|
||||||
buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale });
|
buffer = await expandWithFibo({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale });
|
||||||
modelId = FAL_OUTPAINT_MODEL;
|
modelId = FIBO_EXPAND_MODEL;
|
||||||
} else {
|
} else {
|
||||||
const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl);
|
const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl);
|
||||||
const url = await extendImage(prepared, config, xScale, yScale, requestId);
|
const url = await extendImage(prepared, config, xScale, yScale, requestId);
|
||||||
|
|||||||
@ -72,85 +72,6 @@ function projectRoute() {
|
|||||||
return { route: load("app/api/agent-projects/route.ts"), projects, setUser: (value: string | null) => { userId = value; } };
|
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 () => {
|
test("duplicate POSTs return one project without overwriting saved state", async () => {
|
||||||
const { route, projects } = projectRoute();
|
const { route, projects } = projectRoute();
|
||||||
const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) }));
|
const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) }));
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config";
|
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.describe("image extension provider configuration", () => {
|
||||||
test("uses the most recently updated Alibaba image provider key", () => {
|
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 () => {
|
test("outpainting restores every original pixel in place and keeps the generated border", async () => {
|
||||||
const geometry = getOutpaintGeometry(13, 9, 1.5, 1.5);
|
const geometry = getFiboGeometry(13, 9, 1.5, 1.5);
|
||||||
const { paddedWidth, paddedHeight, left, top } = geometry;
|
const { targetWidth, targetHeight, left, top } = geometry;
|
||||||
const pixel = Buffer.from([42, 93, 190, 255, 30, 80, 20, 75]);
|
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 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 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 expanded = await sharp({ create: { width: targetWidth, height: targetHeight, channels: 3, background: "#d8bc9e" } }).png().toBuffer();
|
||||||
const composed = await composeOutpaint(source, expanded, geometry);
|
const composed = await composeFiboExpansion(source, expanded, geometry);
|
||||||
const area = await sharp(composed).extract({ left, top, width: 13, height: 9 }).raw().toBuffer();
|
const area = await sharp(composed).extract({ left, top, width: 13, height: 9 }).raw().toBuffer();
|
||||||
expect(area.equals(raw)).toBe(true);
|
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]);
|
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", () => {
|
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.targetWidth, g.targetHeight, g.left, g.top]).toEqual([6144, 6144, 1024, 1024]);
|
||||||
expect([g.paddedWidth, g.paddedHeight]).toEqual([2048, 2048]);
|
expect(g.modelWidth * g.modelHeight).toBeLessThan(25_000_000);
|
||||||
expect(g.expandLeft + g.sourceWidth + g.expandRight).toBe(g.paddedWidth);
|
expect(g.modelLeft * 2 + g.sourceWidth).toBeCloseTo(g.modelWidth, -1);
|
||||||
expect(g.expandTop + g.sourceHeight + g.expandBottom).toBe(g.paddedHeight);
|
|
||||||
expect(Math.abs(g.expandLeft - g.expandRight)).toBeLessThanOrEqual(1);
|
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user