import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"; import { useTranslation } from "react-i18next"; import { Message, Modal } from "@arco-design/web-react"; import { createGenTask, getAiModelList } from "@/api/api"; import { MY_CREATIONS_REFRESH_QUERY, ROUTES } from "@/constants"; import { ensureSufficientPoints } from "@/layouts/globalPromptPanel/composerCommon"; import type { AiModelItem } from "@/layouts/globalPromptPanel/types"; import { buildGenTaskModelPayload, normalizeAiModelItem } from "@/layouts/globalPromptPanel/utils"; import { useGenerateCost } from "@/layouts/globalPromptPanel/useGenerateCost"; import { useUserStore } from "@/store/useUserStore"; import "./index.scss"; const EXPAND_IMAGE_SCALE_OPTIONS = ["1.5x", "2x", "3x"] as const; type ExpandImageScale = (typeof EXPAND_IMAGE_SCALE_OPTIONS)[number]; const EXPAND_IMAGE_RATIO_OPTIONS = [ { value: "original", iconClassName: "assetDetailsExpandImageModal__ratioIcon_original" }, { value: "1:1", iconClassName: "assetDetailsExpandImageModal__ratioIcon_1x1" }, { value: "3:4", iconClassName: "assetDetailsExpandImageModal__ratioIcon_3x4" }, { value: "4:3", iconClassName: "assetDetailsExpandImageModal__ratioIcon_4x3" }, { value: "16:9", iconClassName: "assetDetailsExpandImageModal__ratioIcon_16x9" }, ] as const; type ExpandImageRatio = (typeof EXPAND_IMAGE_RATIO_OPTIONS)[number]["value"]; const EXPAND_IMAGE_TASK_TYPE = 1; const EXPAND_IMAGE_SUB_TYPE = 112; const EXPAND_IMAGE_RESOLUTION = "1K"; const EXPAND_IMAGE_BATCH_SIZE = 1; const EXPAND_IMAGE_COST_PARAM_VALUES: Record = { batchSize: EXPAND_IMAGE_BATCH_SIZE }; const EXPAND_IMAGE_PREVIEW_HEIGHT = 369; const EXPAND_IMAGE_ORIGINAL_IMAGE_MAX_SIZE = { width: 140, height: 122 }; const EXPAND_IMAGE_ORIGINAL_FRAME_TOP = 66; const EXPAND_IMAGE_FIXED_RATIO_MAX_SIZE = { width: 560, height: 315 }; const EXPAND_IMAGE_MAX_DIRECTION_RATIO = 0.4; const EXPAND_IMAGE_MAX_AXIS_RATIO = EXPAND_IMAGE_MAX_DIRECTION_RATIO * 2; const EXPAND_IMAGE_RATIO_EPSILON = 0.0001; const EXPAND_IMAGE_SCALE_MULTIPLIERS: Record = { "1.5x": 1.5, "2x": 2, "3x": 3, }; const EXPAND_IMAGE_RATIO_ASPECT_MAP: Record, number> = { "1:1": 1, "3:4": 3 / 4, "4:3": 4 / 3, "16:9": 16 / 9, }; interface ExpandImageSize { width: number; height: number; } interface ExpandImagePosition { x: number; y: number; } interface ExpandImageInsets { top: number; left: number; right: number; bottom: number; } interface ExpandImagePreviewLayout { frameStyle: CSSProperties; imageStyle: CSSProperties; imageSize: ExpandImageSize; maxOffsetX: number; maxOffsetY: number; widthMultiplier: number; heightMultiplier: number; } interface ExpandImageDragState { pointerId: number; startClientX: number; startClientY: number; startPosition: ExpandImagePosition; maxOffsetX: number; maxOffsetY: number; } export interface AssetExpandImageModalProps { visible: boolean; imageUrl: string; originalRatio?: string; onClose: () => void; } function formatExpandImageMultiplier(value: number) { const rounded = Math.round(value * 10) / 10; return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1); } function clampNumber(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function clampExpandImageInsetRatio(value: number) { if (!Number.isFinite(value)) return 0; return clampNumber(Math.round(value * 10000) / 10000, 0, EXPAND_IMAGE_MAX_DIRECTION_RATIO); } function fitExpandImageSize(aspectRatio: number, maxSize: ExpandImageSize): ExpandImageSize { if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) { return maxSize; } const maxAspectRatio = maxSize.width / maxSize.height; if (aspectRatio >= maxAspectRatio) { return { width: maxSize.width, height: Math.round(maxSize.width / aspectRatio), }; } return { width: Math.round(maxSize.height * aspectRatio), height: maxSize.height, }; } function getExpandImageAxisExpandRatio(maxOffset: number, imageSide: number) { if (maxOffset <= 0 || imageSide <= 0) { return 0; } return maxOffset / imageSide; } function isExpandImageAxisWithinLimit(maxOffset: number, imageSide: number) { return getExpandImageAxisExpandRatio(maxOffset, imageSide) <= EXPAND_IMAGE_MAX_AXIS_RATIO + EXPAND_IMAGE_RATIO_EPSILON; } function isExpandImageLayoutWithinLimit(layout: ExpandImagePreviewLayout) { return isExpandImageAxisWithinLimit(layout.maxOffsetX, layout.imageSize.width) && isExpandImageAxisWithinLimit(layout.maxOffsetY, layout.imageSize.height); } function getExpandImageAxisPositionBounds(maxOffset: number, imageSide: number) { const totalExpandRatio = getExpandImageAxisExpandRatio(maxOffset, imageSide); if (totalExpandRatio <= 0) { return { min: 0.5, max: 0.5 }; } if (totalExpandRatio > EXPAND_IMAGE_MAX_AXIS_RATIO + EXPAND_IMAGE_RATIO_EPSILON) { return { min: 0.5, max: 0.5 }; } const maxPosition = Math.min(1, EXPAND_IMAGE_MAX_DIRECTION_RATIO / totalExpandRatio); const minPosition = Math.max(0, 1 - maxPosition); return { min: minPosition, max: maxPosition }; } function clampExpandImagePositionForLayout(position: ExpandImagePosition, layout: ExpandImagePreviewLayout): ExpandImagePosition { const xBounds = getExpandImageAxisPositionBounds(layout.maxOffsetX, layout.imageSize.width); const yBounds = getExpandImageAxisPositionBounds(layout.maxOffsetY, layout.imageSize.height); return { x: clampNumber(position.x, xBounds.min, xBounds.max), y: clampNumber(position.y, yBounds.min, yBounds.max), }; } /** * 后端 extraTaskParams 要的是原图四边外扩比例。 * 这里用“图片在目标框内的位置”反推 top/left/right/bottom,拖到边缘时对应边就是 0。 */ function getExpandImageExtraTaskParams(layout: ExpandImagePreviewLayout, position: ExpandImagePosition): ExpandImageInsets { const clampedPosition = clampExpandImagePositionForLayout(position, layout); const left = layout.maxOffsetX * clampedPosition.x; const top = layout.maxOffsetY * clampedPosition.y; return { top: clampExpandImageInsetRatio(top / layout.imageSize.height), left: clampExpandImageInsetRatio(left / layout.imageSize.width), right: clampExpandImageInsetRatio((layout.maxOffsetX - left) / layout.imageSize.width), bottom: clampExpandImageInsetRatio((layout.maxOffsetY - top) / layout.imageSize.height), }; } function getGreatestCommonDivisor(a: number, b: number): number { let nextA = Math.abs(Math.round(a)); let nextB = Math.abs(Math.round(b)); while (nextB > 0) { const remainder = nextA % nextB; nextA = nextB; nextB = remainder; } return nextA || 1; } function getExpandImageNaturalRatioLabel(imageNaturalSize: ExpandImageSize | null) { if (imageNaturalSize == null) return "1:1"; const divisor = getGreatestCommonDivisor(imageNaturalSize.width, imageNaturalSize.height); return `${String(Math.round(imageNaturalSize.width / divisor))}:${String(Math.round(imageNaturalSize.height / divisor))}`; } function getExpandImageTaskRatio(ratio: ExpandImageRatio, originalRatio: string, imageNaturalSize: ExpandImageSize | null) { if (ratio !== "original") return ratio; const trimmedOriginalRatio = originalRatio.trim(); return trimmedOriginalRatio || getExpandImageNaturalRatioLabel(imageNaturalSize); } function buildExpandImageModelPayload(model: AiModelItem | null): Record { const payload = buildGenTaskModelPayload(model); const platform = (model?.platform ?? "GATEWAY").trim(); if (platform) { payload.aiPlatform = platform; } return payload; } function buildExpandImageTaskPayload(params: { model: AiModelItem | null; image: string; ratio: string; chatPrompt: string; extraTaskParams?: ExpandImageInsets; }): Record | null { const image = params.image.trim(); const code = (params.model?.code ?? "").trim(); const modelId = params.model?.id; if (!image || !code || modelId == null || String(modelId).trim() === "") return null; const modelPayload = buildExpandImageModelPayload(params.model); if (Object.keys(modelPayload).length === 0) return null; return { type: EXPAND_IMAGE_TASK_TYPE, subType: EXPAND_IMAGE_SUB_TYPE, ...modelPayload, ratio: params.ratio, resolution: EXPAND_IMAGE_RESOLUTION, batchSize: EXPAND_IMAGE_BATCH_SIZE, chatPrompt: params.chatPrompt, images: [image], ...(params.extraTaskParams ? { extraTaskParams: params.extraTaskParams } : {}), }; } /** * 预览框只负责展示“最终画布”和“原图可拖动位置”。 * 原比例走倍率放大;固定比例走目标比例框,按短边不动来推导需要扩展的长边。 */ function getExpandImagePreviewLayout(ratio: ExpandImageRatio, scale: ExpandImageScale, imageNaturalSize: ExpandImageSize | null): ExpandImagePreviewLayout { const naturalWidth = Math.max(1, imageNaturalSize?.width ?? 82); const naturalHeight = Math.max(1, imageNaturalSize?.height ?? 122); const naturalAspectRatio = naturalWidth / naturalHeight; if (ratio === "original") { const multiplier = EXPAND_IMAGE_SCALE_MULTIPLIERS[scale]; const imageSize = fitExpandImageSize(naturalAspectRatio, EXPAND_IMAGE_ORIGINAL_IMAGE_MAX_SIZE); const width = Math.round(imageSize.width * multiplier); const height = Math.round(imageSize.height * multiplier); const top = Math.max(2, Math.round(EXPAND_IMAGE_ORIGINAL_FRAME_TOP - (height - EXPAND_IMAGE_ORIGINAL_IMAGE_MAX_SIZE.height * 1.5) / 2)); return { frameStyle: { width, height, top }, imageStyle: imageSize, imageSize, maxOffsetX: Math.max(0, width - imageSize.width), maxOffsetY: Math.max(0, height - imageSize.height), widthMultiplier: multiplier, heightMultiplier: multiplier, }; } const targetAspectRatio = EXPAND_IMAGE_RATIO_ASPECT_MAP[ratio]; let widthMultiplier = 1; let heightMultiplier = 1; if (targetAspectRatio > naturalAspectRatio) { widthMultiplier = targetAspectRatio / naturalAspectRatio; } else { heightMultiplier = naturalAspectRatio / targetAspectRatio; } const frameSize = fitExpandImageSize(targetAspectRatio, EXPAND_IMAGE_FIXED_RATIO_MAX_SIZE); const displayImageWidth = frameSize.width / widthMultiplier; const displayImageHeight = frameSize.height / heightMultiplier; return { frameStyle: { width: Math.round(frameSize.width), height: Math.round(frameSize.height), top: Math.round((EXPAND_IMAGE_PREVIEW_HEIGHT - frameSize.height) / 2), }, imageStyle: { width: Math.round(displayImageWidth), height: Math.round(displayImageHeight), }, imageSize: { width: Math.round(displayImageWidth), height: Math.round(displayImageHeight), }, maxOffsetX: Math.max(0, Math.round(frameSize.width - displayImageWidth)), maxOffsetY: Math.max(0, Math.round(frameSize.height - displayImageHeight)), widthMultiplier, heightMultiplier, }; } function isExpandImageScaleAvailable(scale: ExpandImageScale, imageNaturalSize: ExpandImageSize | null) { return isExpandImageLayoutWithinLimit(getExpandImagePreviewLayout("original", scale, imageNaturalSize)); } function isExpandImageRatioAvailable(ratio: ExpandImageRatio, imageNaturalSize: ExpandImageSize | null) { if (ratio === "original") return true; if (imageNaturalSize == null) return false; return isExpandImageLayoutWithinLimit(getExpandImagePreviewLayout(ratio, "1.5x", imageNaturalSize)); } export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", onClose }: AssetExpandImageModalProps) { const { t } = useTranslation(); const setUser = useUserStore((s) => s.setUser); const createTaskInFlightRef = useRef(false); const dragStateRef = useRef(null); const [scale, setScale] = useState("1.5x"); const [ratio, setRatio] = useState("original"); const [prompt, setPrompt] = useState(""); const [imageNaturalSize, setImageNaturalSize] = useState(null); const [imagePosition, setImagePosition] = useState({ x: 0.5, y: 0.5 }); const [isDraggingImage, setIsDraggingImage] = useState(false); const [creating, setCreating] = useState(false); const [activeModel, setActiveModel] = useState(null); const [modelsLoading, setModelsLoading] = useState(false); const isOriginalRatio = ratio === "original"; const previewLayout = useMemo(() => getExpandImagePreviewLayout(ratio, scale, imageNaturalSize), [imageNaturalSize, ratio, scale]); const selectedLayoutAvailable = isExpandImageLayoutWithinLimit(previewLayout); const boundedImagePosition = useMemo(() => clampExpandImagePositionForLayout(imagePosition, previewLayout), [imagePosition, previewLayout]); const positionedImageStyle = useMemo(() => ({ ...previewLayout.imageStyle, left: Math.round(previewLayout.maxOffsetX * boundedImagePosition.x), top: Math.round(previewLayout.maxOffsetY * boundedImagePosition.y), }), [boundedImagePosition, previewLayout]); const canDragImage = previewLayout.maxOffsetX > 0 || previewLayout.maxOffsetY > 0; const taskRatio = useMemo(() => getExpandImageTaskRatio(ratio, originalRatio, imageNaturalSize), [imageNaturalSize, originalRatio, ratio]); const resolvedPrompt = useMemo(() => prompt.trim() || t("pages.assetDetails.expandImage.defaultPrompt"), [prompt, t]); const costPayload = useMemo( () => visible ? buildExpandImageTaskPayload({ model: activeModel, image: imageUrl, ratio: taskRatio, chatPrompt: resolvedPrompt, }) ?? undefined : undefined, [activeModel, imageUrl, resolvedPrompt, taskRatio, visible], ); const costResult = useGenerateCost({ activeModelItem: visible ? activeModel : null, selectedParamValues: EXPAND_IMAGE_COST_PARAM_VALUES, taskPayload: costPayload, activeTaskSubType: EXPAND_IMAGE_SUB_TYPE, chatPrompt: resolvedPrompt, }); const createCost = costResult.cost; const modelReady = !modelsLoading && activeModel != null && costPayload != null; const canCreateExpandImage = modelReady && imageNaturalSize != null && selectedLayoutAvailable; const scaleHintText = isOriginalRatio ? scale : t("pages.assetDetails.expandImage.sizeHint", { width: formatExpandImageMultiplier(previewLayout.widthMultiplier), height: formatExpandImageMultiplier(previewLayout.heightMultiplier), }); const resetImageDragState = () => { setImagePosition({ x: 0.5, y: 0.5 }); dragStateRef.current = null; setIsDraggingImage(false); }; const handleScaleChange = (nextScale: ExpandImageScale) => { if (nextScale === scale) return; if (!isExpandImageScaleAvailable(nextScale, imageNaturalSize)) return; resetImageDragState(); setScale(nextScale); }; const handleRatioChange = (nextRatio: ExpandImageRatio) => { if (nextRatio === ratio) return; if (!isExpandImageRatioAvailable(nextRatio, imageNaturalSize)) return; resetImageDragState(); setRatio(nextRatio); }; useEffect(() => { setScale("1.5x"); setRatio("original"); setPrompt(""); setImageNaturalSize(null); resetImageDragState(); }, [imageUrl]); useEffect(() => { setImagePosition({ x: 0.5, y: 0.5 }); dragStateRef.current = null; setIsDraggingImage(false); }, [imageUrl, ratio, scale]); useEffect(() => { if (!visible) return; let cancelled = false; setActiveModel(null); setModelsLoading(true); // 模型列表属于扩图弹窗内部字段,父级不需要知道具体模型结构。 void getAiModelList({ subType: EXPAND_IMAGE_SUB_TYPE }) .then((res) => { if (cancelled) return; const list = ((res as { data?: unknown[] }).data ?? []).map((item) => normalizeAiModelItem(item as AiModelItem)); setActiveModel(list[0] ?? null); }) .catch(() => { if (!cancelled) { setActiveModel(null); } }) .finally(() => { if (!cancelled) setModelsLoading(false); }); return () => { cancelled = true; }; }, [visible]); const handleImagePointerDown = (event: ReactPointerEvent) => { if (!canDragImage || creating || !selectedLayoutAvailable) return; event.preventDefault(); const clampedPosition = clampExpandImagePositionForLayout(imagePosition, previewLayout); dragStateRef.current = { pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, startPosition: clampedPosition, maxOffsetX: previewLayout.maxOffsetX, maxOffsetY: previewLayout.maxOffsetY, }; setImagePosition(clampedPosition); setIsDraggingImage(true); event.currentTarget.setPointerCapture(event.pointerId); }; const handleImagePointerMove = (event: ReactPointerEvent) => { const dragState = dragStateRef.current; if (dragState == null || dragState.pointerId !== event.pointerId) return; event.preventDefault(); const nextPosition = { x: dragState.maxOffsetX > 0 ? (dragState.startPosition.x * dragState.maxOffsetX + event.clientX - dragState.startClientX) / dragState.maxOffsetX : 0.5, y: dragState.maxOffsetY > 0 ? (dragState.startPosition.y * dragState.maxOffsetY + event.clientY - dragState.startClientY) / dragState.maxOffsetY : 0.5, }; setImagePosition(clampExpandImagePositionForLayout(nextPosition, previewLayout)); }; const finishImageDrag = (event: ReactPointerEvent) => { const dragState = dragStateRef.current; if (dragState == null || dragState.pointerId !== event.pointerId) return; dragStateRef.current = null; setIsDraggingImage(false); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }; const handleCreate = async () => { if (createTaskInFlightRef.current || creating || !canCreateExpandImage) return; const payload = buildExpandImageTaskPayload({ model: activeModel, image: imageUrl, ratio: taskRatio, chatPrompt: resolvedPrompt, extraTaskParams: getExpandImageExtraTaskParams(previewLayout, boundedImagePosition), }); if (payload == null) { Message.error(t("pages.assetDetails.expandImage.modelRequired")); return; } createTaskInFlightRef.current = true; setCreating(true); try { if (!(await ensureSufficientPoints(createCost))) return; Message.success(t("composer.taskCreating")); await createGenTask(payload); onClose(); void setUser(); const href = `${ROUTES.CREATE_MY_CREATIONS}?type=1&${MY_CREATIONS_REFRESH_QUERY}=${String(Date.now())}`; globalThis.location.assign(href); return; } catch { /* request interceptor shows the error. */ } finally { createTaskInFlightRef.current = false; setCreating(false); } }; if (!visible) return null; return (

{t("pages.assetDetails.expandImage.title")}

{scaleHintText} {isOriginalRatio ? (
{EXPAND_IMAGE_SCALE_OPTIONS.map((scaleOption) => ( ))}
) : null}
{EXPAND_IMAGE_RATIO_OPTIONS.map((ratioOption) => { const ratioLabel = ratioOption.value === "original" ? t("pages.assetDetails.expandImage.ratioOriginal") : ratioOption.value; const ratioDisabled = !isExpandImageRatioAvailable(ratioOption.value, imageNaturalSize); return ( ); })}
{ setPrompt(event.target.value); }} aria-label={t("pages.assetDetails.expandImage.promptAria")} />
); }