|
|
|
@ -31,6 +31,9 @@ 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, |
|
|
|
@ -97,7 +100,7 @@ function clampNumber(value: number, min: number, max: number) { |
|
|
|
|
|
|
|
function clampExpandImageInsetRatio(value: number) { |
|
|
|
if (!Number.isFinite(value)) return 0; |
|
|
|
return clampNumber(Math.round(value * 10000) / 10000, 0, 1); |
|
|
|
return clampNumber(Math.round(value * 10000) / 10000, 0, EXPAND_IMAGE_MAX_DIRECTION_RATIO); |
|
|
|
} |
|
|
|
|
|
|
|
function fitExpandImageSize(aspectRatio: number, maxSize: ExpandImageSize): ExpandImageSize { |
|
|
|
@ -117,11 +120,34 @@ function fitExpandImageSize(aspectRatio: number, maxSize: ExpandImageSize): Expa |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
function getExpandImageAxisPositionBounds(maxOffset: number, imageSide: number) { |
|
|
|
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 }; |
|
|
|
} |
|
|
|
return { min: 0, max: 1 }; |
|
|
|
|
|
|
|
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 { |
|
|
|
@ -268,6 +294,16 @@ function getExpandImagePreviewLayout(ratio: ExpandImageRatio, scale: ExpandImage |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
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); |
|
|
|
@ -285,6 +321,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o |
|
|
|
|
|
|
|
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, |
|
|
|
@ -312,6 +349,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o |
|
|
|
}); |
|
|
|
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", { |
|
|
|
@ -327,12 +365,14 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o |
|
|
|
|
|
|
|
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); |
|
|
|
}; |
|
|
|
@ -379,7 +419,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o |
|
|
|
}, [visible]); |
|
|
|
|
|
|
|
const handleImagePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => { |
|
|
|
if (!canDragImage || creating) return; |
|
|
|
if (!canDragImage || creating || !selectedLayoutAvailable) return; |
|
|
|
event.preventDefault(); |
|
|
|
const clampedPosition = clampExpandImagePositionForLayout(imagePosition, previewLayout); |
|
|
|
dragStateRef.current = { |
|
|
|
@ -421,7 +461,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o |
|
|
|
}; |
|
|
|
|
|
|
|
const handleCreate = async () => { |
|
|
|
if (createTaskInFlightRef.current || creating || !modelReady || imageNaturalSize == null) return; |
|
|
|
if (createTaskInFlightRef.current || creating || !canCreateExpandImage) return; |
|
|
|
const payload = buildExpandImageTaskPayload({ |
|
|
|
model: activeModel, |
|
|
|
image: imageUrl, |
|
|
|
@ -512,6 +552,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o |
|
|
|
role="radio" |
|
|
|
aria-checked={scaleOption === scale} |
|
|
|
aria-label={t("pages.assetDetails.expandImage.scaleOptionAria", { scale: scaleOption })} |
|
|
|
disabled={!isExpandImageScaleAvailable(scaleOption, imageNaturalSize)} |
|
|
|
onClick={() => { |
|
|
|
handleScaleChange(scaleOption); |
|
|
|
}} |
|
|
|
@ -526,6 +567,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o |
|
|
|
<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} |
|
|
|
@ -537,6 +579,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o |
|
|
|
role="radio" |
|
|
|
aria-checked={ratioOption.value === ratio} |
|
|
|
aria-label={t("pages.assetDetails.expandImage.ratioOptionAria", { ratio: ratioLabel })} |
|
|
|
disabled={ratioDisabled} |
|
|
|
onClick={() => { |
|
|
|
handleRatioChange(ratioOption.value); |
|
|
|
}} |
|
|
|
@ -561,7 +604,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o |
|
|
|
<button |
|
|
|
type="button" |
|
|
|
className="assetDetailsExpandImageModal__createButton" |
|
|
|
disabled={creating || !modelReady || imageNaturalSize == null} |
|
|
|
disabled={creating || !canCreateExpandImage} |
|
|
|
aria-busy={creating} |
|
|
|
aria-label={t("pages.assetDetails.expandImage.createAria", { credits: createCost })} |
|
|
|
onClick={() => { void handleCreate(); }} |
|
|
|
|