diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index da2ab194a..dc34c7e2e 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -45,6 +45,8 @@ type LocalEditRequest = { model?: string; imageSize?: string; aspectRatio?: string; + operation?: string; + scalePercent?: number; }; type NormalizedLocalEditRegion = { @@ -76,6 +78,9 @@ type NormalizedLocalEditRegion = { const MAX_LOCAL_EDIT_REGIONS = 8; const MAX_REFERENCE_IMAGES_PER_REGION = 3; const MIN_REGION_EDGE_PX = 24; +const SCALE_ADJUST_OPERATION = "scale-adjust"; +const SCALE_ADJUST_PERCENT_OPTIONS = [-30, -20, -10, 10, 20, 30] as const; +type ScaleAdjustPercent = (typeof SCALE_ADJUST_PERCENT_OPTIONS)[number]; const DEFAULT_REGION_COLORS = [ "#ff3b30", "#1677ff", @@ -201,6 +206,24 @@ function normalizeRegionReferenceImages( .slice(0, MAX_REFERENCE_IMAGES_PER_REGION); } +function resolveLocalEditOperation(input: unknown) { + return String(input || "").trim() === SCALE_ADJUST_OPERATION + ? SCALE_ADJUST_OPERATION + : "local-edit"; +} + +function normalizeScaleAdjustPercent(input: unknown): ScaleAdjustPercent { + const value = Math.round(Number(input)); + if ((SCALE_ADJUST_PERCENT_OPTIONS as readonly number[]).includes(value)) { + return value as ScaleAdjustPercent; + } + throw new Error("请选择有效的比例调整幅度"); +} + +function getScaleAdjustLabel(scalePercent: ScaleAdjustPercent) { + return scalePercent > 0 ? `放大 ${scalePercent}%` : `缩小 ${Math.abs(scalePercent)}%`; +} + function getColorName(hex: string) { const normalized = hex.toLowerCase(); const names: Record = { @@ -272,7 +295,8 @@ function wrapText(text: string, maxChars: number, maxLines = 3) { function normalizeRegions( rawRegions: LocalEditRegionInput[] | undefined, sourceWidth: number, - sourceHeight: number + sourceHeight: number, + options: { requirePrompt?: boolean; fallbackPrompt?: string } = {} ): NormalizedLocalEditRegion[] { if (!Array.isArray(rawRegions) || rawRegions.length === 0) { throw new Error("请先框选至少一个局部编辑区域"); @@ -287,7 +311,8 @@ function normalizeRegions( throw new Error(`区域 ${itemIndex + 1} 缺少有效框选范围`); } const prompt = String(region.prompt || "").trim(); - if (!prompt) { + const resolvedPrompt = prompt || String(options.fallbackPrompt || "").trim(); + if (options.requirePrompt !== false && !resolvedPrompt) { throw new Error(`区域 ${itemIndex + 1} 请填写局部编辑提示词`); } const pixelRect = rectToPixels(rect, sourceWidth, sourceHeight); @@ -303,7 +328,7 @@ function normalizeRegions( index: itemIndex + 1, color, colorName: getColorName(color), - prompt: prompt.slice(0, 300), + prompt: resolvedPrompt.slice(0, 300), referenceImages: normalizeRegionReferenceImages(region, itemIndex), rect, pixelRect, @@ -414,6 +439,40 @@ function buildLocalEditPrompt(regions: NormalizedLocalEditRegion[]) { ].join("\n"); } +function buildScaleAdjustPrompt( + regions: NormalizedLocalEditRegion[], + scalePercent: ScaleAdjustPercent +) { + const scaleFactor = Math.round((1 + scalePercent / 100) * 100) / 100; + const actionLabel = getScaleAdjustLabel(scalePercent); + const regionLines = regions + .map((region) => { + const left = Math.round(region.rect.x * 1000) / 10; + const top = Math.round(region.rect.y * 1000) / 10; + const width = Math.round(region.rect.width * 1000) / 10; + const height = Math.round(region.rect.height * 1000) / 10; + return `区域 ${region.index}(原图位置 x=${left}%,y=${top}%,w=${width}%,h=${height}%):识别该框选范围内的完整主体物体,以主体视觉中心为锚点执行整体等比${actionLabel},目标尺寸为原物体的 ${Math.round(scaleFactor * 100)}%。`; + }) + .join("\n"); + + return [ + "输入图片1是原始干净图,也是最终输出必须继承的唯一画面基底。", + "系统已根据用户框选区域计算出下方每个区域的原图相对坐标;彩色框只存在于产品界面里,不会作为最终画面内容。", + "最终结果必须保持输入图片1的原始画幅比例、构图边界、镜头透视、光线、色温、材质质感和清晰度。", + "请不要生成任何彩色框、矩形边框、编号圆点、提示词气泡、标注文字、UI 控件或半透明色块。", + "", + `任务:对用户框选的主体物体做“比例调整”,统一执行${actionLabel}。`, + regionLines, + "", + "关键要求:", + "1. 只调整框选主体物体的整体视觉比例,必须等比缩放,不得压扁、拉伸、扭曲、改变外观结构、改变材质、改变颜色、改变文字或图案。", + "2. 缩小时,主体应围绕原位置中心自然变小,露出的背景需要与原图环境、纹理、阴影和透视自然补全。", + "3. 放大时,主体应围绕原位置中心自然变大,边缘与遮挡关系要合理,不能变形,也不能超出画面造成突兀裁切。", + "4. 未被框选的其它画面区域必须保持原图不变,不允许重绘、改色、补光、移动物体或改变背景细节。", + "5. 最终输出必须是一张干净照片,不得出现任何选区框、说明文字、对比线、比例数字或 UI 元素。", + ].join("\n"); +} + export async function POST(request: NextRequest) { try { const session = await auth(); @@ -440,9 +499,34 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, error: "无法识别原图尺寸" }, { status: 400 }); } + const operation = resolveLocalEditOperation(body.operation); + let scalePercent: ScaleAdjustPercent | null = null; + if (operation === SCALE_ADJUST_OPERATION) { + try { + scalePercent = normalizeScaleAdjustPercent(body.scalePercent); + } catch (validationError) { + return NextResponse.json( + { + success: false, + error: + validationError instanceof Error + ? validationError.message + : "比例调整参数无效", + }, + { status: 400 } + ); + } + } + let regions: NormalizedLocalEditRegion[]; try { - regions = normalizeRegions(body.regions, sourceWidth, sourceHeight); + regions = normalizeRegions(body.regions, sourceWidth, sourceHeight, { + fallbackPrompt: + operation === SCALE_ADJUST_OPERATION && scalePercent !== null + ? `比例调整:${getScaleAdjustLabel(scalePercent)}` + : undefined, + requirePrompt: operation !== SCALE_ADJUST_OPERATION, + }); } catch (validationError) { return NextResponse.json( { @@ -460,9 +544,10 @@ export async function POST(request: NextRequest) { const pointsCharge = await prepareFeaturePointsCharge({ userId: session.user.id, unitCost: getImageToolCreditCost(pointsConfig, "repaint"), - reason: "image_repaint", - modelId: "canvas-local-edit", - note: "局部编辑消耗积分", + reason: operation === SCALE_ADJUST_OPERATION ? "image_scale_adjust" : "image_repaint", + modelId: + operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit", + note: operation === SCALE_ADJUST_OPERATION ? "比例调整消耗积分" : "局部编辑消耗积分", pointsConfig, }); if (!pointsCharge.ok) { @@ -483,7 +568,10 @@ export async function POST(request: NextRequest) { annotatedFilename, toArrayBuffer(annotatedBuffer) ); - const prompt = buildLocalEditPrompt(regions); + const prompt = + operation === SCALE_ADJUST_OPERATION && scalePercent !== null + ? buildScaleAdjustPrompt(regions, scalePercent) + : buildLocalEditPrompt(regions); const regionReferenceImageUrls = regions.flatMap((region) => region.referenceImages.map((reference) => reference.url) ); @@ -496,13 +584,16 @@ export async function POST(request: NextRequest) { const imageRequest: CanvasGenerateImageRequest = { aspectRatio: resolvedAspectRatio, billingFeature: "repaint", - debugLabel: "canvas-local-edit", + debugLabel: + operation === SCALE_ADJUST_OPERATION ? "canvas-scale-adjust" : "canvas-local-edit", debugMeta: { annotatedReferenceUrl: annotatedUpload.url, annotatedReferenceSentToModel: false, + operation, resolvedAspectRatio, regionReferenceImageCount: regionReferenceImageUrls.length, regionCount: regions.length, + scalePercent, sourceHeight, sourceNodeId: body.sourceNodeId || null, sourceWidth, @@ -544,7 +635,10 @@ export async function POST(request: NextRequest) { if (pointsCharge.chargeable) { try { await pointsCharge.consume({ - note: `局部编辑消耗积分(${task.id})`, + note: + operation === SCALE_ADJUST_OPERATION + ? `比例调整消耗积分(${task.id})` + : `局部编辑消耗积分(${task.id})`, }); } catch (pointsError) { console.error("[Points] Failed to consume local edit points:", pointsError); diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index 67fdd537d..b42f604d1 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -26,6 +26,7 @@ import { ImagePlus, Loader2, Maximize2, + Minimize2, Sparkles, Upload, UserRound, @@ -232,10 +233,14 @@ type BatchDownloadDialogState = { type DetailResultLocalEditTarget = { cardId: string; imageUrl: string; + mode?: DetailResultLocalEditMode; title: string; resultModeType: "detail_generation" | "detail_replacement"; }; +type DetailResultLocalEditMode = "local_edit" | "scale_adjust"; +type DetailResultScalePercent = -30 | -20 | -10 | 10 | 20 | 30; + type DetailResultLocalEditRect = { x: number; y: number; @@ -245,6 +250,8 @@ type DetailResultLocalEditRect = { type DetailResultLocalEditSubmitParams = { imageUrl: string; + mode: DetailResultLocalEditMode; + scalePercent?: DetailResultScalePercent; title: string; regions: LocalEditRegion[]; }; @@ -255,6 +262,14 @@ const MAX_CHAT_ATTACHMENT_NAME_LENGTH = 100; const MAX_AGENT_MODE_QUERY_LENGTH = 64; const MAX_AGENT_MODE_REFERENCE_COUNT = 40; const DETAIL_LOCAL_EDIT_REFERENCE_MAX_UPLOAD_BYTES = 30 * 1024 * 1024; +const DETAIL_SCALE_ADJUST_OPTIONS: DetailResultScalePercent[] = [ + -30, + -20, + -10, + 10, + 20, + 30, +]; const DIRECT_MODEL_UPLOAD_SLOT_COUNT = 4; const PRODUCT_DETAIL_CHANNEL_OPTIONS: Array<{ value: AgentWorkflowProductDetailChannel; @@ -784,6 +799,14 @@ async function uploadDetailLocalEditReferenceImage( }; } +function getDetailScaleAdjustLabel(scalePercent: DetailResultScalePercent) { + return scalePercent > 0 ? `放大 ${scalePercent}%` : `缩小 ${Math.abs(scalePercent)}%`; +} + +function getDetailScaleAdjustRegionPrompt(scalePercent: DetailResultScalePercent | null) { + return scalePercent ? `比例调整:${getDetailScaleAdjustLabel(scalePercent)}` : "比例调整"; +} + function DetailResultLocalEditDialog({ submitting, target, @@ -799,15 +822,21 @@ function DetailResultLocalEditDialog({ const [draftRect, setDraftRect] = useState(null); const [activeRegionId, setActiveRegionId] = useState(null); const [uploadingRegionId, setUploadingRegionId] = useState(null); + const [selectedScalePercent, setSelectedScalePercent] = + useState(null); const dragStartRef = useRef<{ x: number; y: number } | null>(null); + const mode: DetailResultLocalEditMode = + target?.mode === "scale_adjust" ? "scale_adjust" : "local_edit"; + const isScaleAdjustMode = mode === "scale_adjust"; useEffect(() => { setRegions([]); setDraftRect(null); setActiveRegionId(null); setUploadingRegionId(null); + setSelectedScalePercent(null); dragStartRef.current = null; - }, [target?.cardId, target?.imageUrl]); + }, [target?.cardId, target?.imageUrl, target?.mode]); useEffect(() => { if (regions.length === 0) { @@ -952,7 +981,9 @@ function DetailResultLocalEditDialog({ const validRegions = reindexLocalEditRegions( regions.map((region) => ({ ...region, - prompt: region.prompt.trim(), + prompt: isScaleAdjustMode + ? getDetailScaleAdjustRegionPrompt(selectedScalePercent) + : region.prompt.trim(), referenceImages: (region.referenceImages || []).slice( 0, LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION @@ -972,18 +1003,27 @@ function DetailResultLocalEditDialog({ toast.error(`区域 ${invalidRegion.index} 太小,请重新框选`); return; } - const emptyPromptRegion = validRegions.find((region) => !region.prompt); - if (emptyPromptRegion) { - setActiveRegionId(emptyPromptRegion.id); - toast.error(`请填写区域 ${emptyPromptRegion.index} 的提示词`); - return; + if (isScaleAdjustMode) { + if (!selectedScalePercent) { + toast.error("请选择放大或缩小比例"); + return; + } + } else { + const emptyPromptRegion = validRegions.find((region) => !region.prompt); + if (emptyPromptRegion) { + setActiveRegionId(emptyPromptRegion.id); + toast.error(`请填写区域 ${emptyPromptRegion.index} 的提示词`); + return; + } } onSubmit({ imageUrl: target.imageUrl, + mode, + scalePercent: isScaleAdjustMode ? selectedScalePercent || undefined : undefined, title: target.title, regions: validRegions, }); - }, [onSubmit, regions, target, uploadingRegionId]); + }, [isScaleAdjustMode, mode, onSubmit, regions, selectedScalePercent, target, uploadingRegionId]); const completeDraftRegion = useCallback( (event: React.PointerEvent) => { @@ -1008,7 +1048,9 @@ function DetailResultLocalEditDialog({ id: createLocalEditRegionId(), index: regions.length + 1, color: LOCAL_EDIT_COLORS[regions.length % LOCAL_EDIT_COLORS.length], - prompt: "", + prompt: isScaleAdjustMode + ? getDetailScaleAdjustRegionPrompt(selectedScalePercent) + : "", rect: nextRect, referenceImages: [], createdAt: Date.now(), @@ -1019,7 +1061,7 @@ function DetailResultLocalEditDialog({ }); setActiveRegionId(nextRegion.id); }, - [createRectFromPoints, getPoint, regions.length] + [createRectFromPoints, getPoint, isScaleAdjustMode, regions.length, selectedScalePercent] ); const completionCopy = @@ -1027,14 +1069,18 @@ function DetailResultLocalEditDialog({ ? "处理完成后会更新当前套详情结果图。" : "处理完成后会加入 AI详情结果区。"; const isBusy = submitting || Boolean(uploadingRegionId); + const dialogTitle = isScaleAdjustMode ? "比例调整详情图" : "局部编辑详情图"; + const dialogDescription = isScaleAdjustMode + ? "框选要调整比例的物体,选择固定放大或缩小幅度后生成。" + : "在图片上拖拽框选编辑位置,可添加多个区域,并为每个区域上传专属参考图。"; return ( (!open ? onClose() : undefined)}> - 局部编辑详情图 + {dialogTitle} - 在图片上拖拽框选编辑位置,可添加多个区域,并为每个区域上传专属参考图。 + {dialogDescription}
@@ -1053,7 +1099,9 @@ function DetailResultLocalEditDialog({ onPointerDown={(event) => { if (event.button !== 0 || isBusy) return; if (regions.length >= LOCAL_EDIT_MAX_REGIONS) { - toast.info(`局部编辑最多支持 ${LOCAL_EDIT_MAX_REGIONS} 个区域`); + toast.info( + `${isScaleAdjustMode ? "比例调整" : "局部编辑"}最多支持 ${LOCAL_EDIT_MAX_REGIONS} 个区域` + ); return; } event.preventDefault(); @@ -1131,12 +1179,45 @@ function DetailResultLocalEditDialog({
- 拖拽图片添加区域。最多 {LOCAL_EDIT_MAX_REGIONS} 个区域,每个区域最多{" "} - {LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图。{completionCopy} + {isScaleAdjustMode + ? `拖拽框选物体,建议留出少量周边背景,最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}` + : `拖拽图片添加区域。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域,每个区域最多 ${LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} 张参考图。${completionCopy}`}
+ {isScaleAdjustMode ? ( +
+
+ Scale +
+
+ {DETAIL_SCALE_ADJUST_OPTIONS.map((option) => { + const active = selectedScalePercent === option; + const Icon = option > 0 ? Maximize2 : Minimize2; + return ( + + ); + })} +
+
+ ) : null} {regions.length === 0 ? (
- 在左侧图片上拖拽框选后,这里会显示区域提示词和参考图。 + {isScaleAdjustMode + ? "在左侧图片上拖拽框选物体后,选择一个比例即可生成。" + : "在左侧图片上拖拽框选后,这里会显示区域提示词和参考图。"}
) : (
@@ -1168,10 +1249,18 @@ function DetailResultLocalEditDialog({ 区域 {region.index} - - 参考图 {references.length}/ - {LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} - + {isScaleAdjustMode ? ( + + {selectedScalePercent + ? getDetailScaleAdjustLabel(selectedScalePercent) + : "待选比例"} + + ) : ( + + 参考图 {references.length}/ + {LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION} + + )}
-