You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
619 lines
26 KiB
619 lines
26 KiB
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<string, string | number> = { 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<ExpandImageScale, number> = {
|
|
"1.5x": 1.5,
|
|
"2x": 2,
|
|
"3x": 3,
|
|
};
|
|
const EXPAND_IMAGE_RATIO_ASPECT_MAP: Record<Exclude<ExpandImageRatio, "original">, 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<string, unknown> {
|
|
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<string, unknown> | 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<ExpandImageDragState | null>(null);
|
|
const [scale, setScale] = useState<ExpandImageScale>("1.5x");
|
|
const [ratio, setRatio] = useState<ExpandImageRatio>("original");
|
|
const [prompt, setPrompt] = useState("");
|
|
const [imageNaturalSize, setImageNaturalSize] = useState<ExpandImageSize | null>(null);
|
|
const [imagePosition, setImagePosition] = useState<ExpandImagePosition>({ x: 0.5, y: 0.5 });
|
|
const [isDraggingImage, setIsDraggingImage] = useState(false);
|
|
const [creating, setCreating] = useState(false);
|
|
const [activeModel, setActiveModel] = useState<AiModelItem | null>(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<CSSProperties>(() => ({
|
|
...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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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<HTMLDivElement>) => {
|
|
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 (
|
|
<Modal visible={visible} title={null} footer={null} closable={false} maskClosable escToExit onCancel={onClose} alignCenter unmountOnExit className="assetDetailsExpandImageModal" style={{ width: 838, padding: 0 }}>
|
|
<div className="assetDetailsExpandImageModal__content">
|
|
<button type="button" className="assetDetailsExpandImageModal__close" onClick={onClose} aria-label={t("button.cancel")}>
|
|
<img src={new URL("/src/assets/images/ic_close.png", import.meta.url).href} alt="" />
|
|
</button>
|
|
<h3 className="assetDetailsExpandImageModal__title">{t("pages.assetDetails.expandImage.title")}</h3>
|
|
|
|
<div className="assetDetailsExpandImageModal__preview">
|
|
<span className="assetDetailsExpandImageModal__scaleHint">{scaleHintText}</span>
|
|
<div className="assetDetailsExpandImageModal__selection" style={previewLayout.frameStyle} aria-hidden="true">
|
|
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_topLeft" />
|
|
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_topCenter" />
|
|
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_topRight" />
|
|
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_rightCenter" />
|
|
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_bottomRight" />
|
|
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_bottomCenter" />
|
|
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_bottomLeft" />
|
|
<span className="assetDetailsExpandImageModal__selectionHandle assetDetailsExpandImageModal__selectionHandle_leftCenter" />
|
|
<div
|
|
className={[
|
|
"assetDetailsExpandImageModal__selectionImageWrap",
|
|
canDragImage ? "assetDetailsExpandImageModal__selectionImageWrap_draggable" : "",
|
|
isDraggingImage ? "assetDetailsExpandImageModal__selectionImageWrap_dragging" : "",
|
|
].filter(Boolean).join(" ")}
|
|
style={positionedImageStyle}
|
|
onPointerDown={handleImagePointerDown}
|
|
onPointerMove={handleImagePointerMove}
|
|
onPointerUp={finishImageDrag}
|
|
onPointerCancel={finishImageDrag}
|
|
>
|
|
<img
|
|
className="assetDetailsExpandImageModal__selectionImage"
|
|
src={imageUrl}
|
|
alt=""
|
|
onLoad={(event) => {
|
|
const image = event.currentTarget;
|
|
setImageNaturalSize({
|
|
width: image.naturalWidth || image.width,
|
|
height: image.naturalHeight || image.height,
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{isOriginalRatio ? (
|
|
<div className="assetDetailsExpandImageModal__scaleControl" role="radiogroup" aria-label={t("pages.assetDetails.expandImage.scaleAria")}>
|
|
{EXPAND_IMAGE_SCALE_OPTIONS.map((scaleOption) => (
|
|
<button
|
|
key={scaleOption}
|
|
type="button"
|
|
className={[
|
|
"assetDetailsExpandImageModal__scaleButton",
|
|
scaleOption === scale ? "assetDetailsExpandImageModal__scaleButton_active" : "",
|
|
].filter(Boolean).join(" ")}
|
|
role="radio"
|
|
aria-checked={scaleOption === scale}
|
|
aria-label={t("pages.assetDetails.expandImage.scaleOptionAria", { scale: scaleOption })}
|
|
disabled={!isExpandImageScaleAvailable(scaleOption, imageNaturalSize)}
|
|
onClick={() => {
|
|
handleScaleChange(scaleOption);
|
|
}}
|
|
>
|
|
{scaleOption}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="assetDetailsExpandImageModal__ratioBar" role="radiogroup" aria-label={t("pages.assetDetails.expandImage.ratioAria")}>
|
|
{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 (
|
|
<button
|
|
key={ratioOption.value}
|
|
type="button"
|
|
className={[
|
|
"assetDetailsExpandImageModal__ratioButton",
|
|
ratioOption.value === ratio ? "assetDetailsExpandImageModal__ratioButton_active" : "",
|
|
].filter(Boolean).join(" ")}
|
|
role="radio"
|
|
aria-checked={ratioOption.value === ratio}
|
|
aria-label={t("pages.assetDetails.expandImage.ratioOptionAria", { ratio: ratioLabel })}
|
|
disabled={ratioDisabled}
|
|
onClick={() => {
|
|
handleRatioChange(ratioOption.value);
|
|
}}
|
|
>
|
|
<span className={["assetDetailsExpandImageModal__ratioIcon", ratioOption.iconClassName].join(" ")} aria-hidden="true" />
|
|
<span className="assetDetailsExpandImageModal__ratioLabel">{ratioLabel}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="assetDetailsExpandImageModal__promptBlock">
|
|
<input
|
|
className="assetDetailsExpandImageModal__promptInput"
|
|
value={prompt}
|
|
placeholder={t("pages.assetDetails.expandImage.promptPlaceholder")}
|
|
onChange={(event) => {
|
|
setPrompt(event.target.value);
|
|
}}
|
|
aria-label={t("pages.assetDetails.expandImage.promptAria")}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="assetDetailsExpandImageModal__createButton"
|
|
disabled={creating || !canCreateExpandImage}
|
|
aria-busy={creating}
|
|
aria-label={t("pages.assetDetails.expandImage.createAria", { credits: createCost })}
|
|
onClick={() => { void handleCreate(); }}
|
|
>
|
|
<img className="assetDetailsExpandImageModal__createButtonIcon" src={new URL("/src/assets/images/ic_stars_white.png", import.meta.url).href} alt="" />
|
|
<span className="assetDetailsExpandImageModal__createButtonText">{createCost}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|