
setImageRetryNonce(0)}
+ onLoad={(event) => {
+ setImageRetryNonce(0);
+ const image = event.currentTarget;
+ if (image.naturalWidth > 0 && image.naturalHeight > 0) {
+ setCropImageDimensions({ width: image.naturalWidth, height: image.naturalHeight });
+ }
+ }}
onError={() => {
if (nodeData.previewImg && displayImage === nodeData.previewImg && nodeData.outputImage && nodeData.outputImage !== nodeData.previewImg) {
setDisplayFallbackImage(nodeData.outputImage);
@@ -428,6 +556,17 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
}}
className="w-full h-full object-contain cursor-zoom-in"
/>
+ {isCropping && cropImageDimensions && (
+
+ )}
{/* Loading overlay for generation */}
{nodeData.status === "loading" && (
diff --git a/src/components/nodes/ImageInputNode.tsx b/src/components/nodes/ImageInputNode.tsx
index 655089d8..8828db50 100644
--- a/src/components/nodes/ImageInputNode.tsx
+++ b/src/components/nodes/ImageInputNode.tsx
@@ -24,6 +24,7 @@ import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCap
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal";
import { MediaCropOverlay, type MediaCropAspectRatio, type MediaCropConfirmPayload } from "@/components/media/MediaCropOverlay";
import { CropRatioSelect, MEDIA_EDIT_TOOL, MediaEditToolbar, type MediaEditTool, type MediaEditToolOption } from "@/components/media/MediaEditToolbar";
+import { createCroppedImageBlob } from "@/components/media/imageCropUtils";
type ImageInputNodeType = Node;
const IMAGE_INPUT_DIMENSIONS = defaultNodeDimensions.imageInput;
@@ -37,20 +38,6 @@ const IMAGE_MEDIA_EDIT_TOOLS: MediaEditToolOption[] = [
{ key: MEDIA_EDIT_TOOL.cutout, label: "抠图" },
];
-function getCropDrawableImageSource(source: string) {
- return /^https?:\/\//i.test(source) ? `/api/proxy-media?url=${encodeURIComponent(source)}` : source;
-}
-
-function loadCropImage(source: string): Promise {
- return new Promise((resolve, reject) => {
- const image = new Image();
- image.crossOrigin = "anonymous";
- image.onload = () => resolve(image);
- image.onerror = () => reject(new Error("Image failed to load"));
- image.src = getCropDrawableImageSource(source);
- });
-}
-
export interface ImageInputNodeViewProps {
id: string;
data: ImageInputNodeData;
@@ -309,30 +296,9 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal
const sourceImage = nodeData.image || displayImage;
if (!sourceImage) return;
- const canvas = document.createElement("canvas");
- canvas.width = payload.outputWidth;
- canvas.height = payload.outputHeight;
- const context = canvas.getContext("2d");
- if (!context) {
- alert(t("imageInput.cropFailed"));
- return;
- }
-
let blob: Blob | null = null;
try {
- const imageElement = await loadCropImage(sourceImage);
- context.drawImage(
- imageElement,
- payload.cropRect.x,
- payload.cropRect.y,
- payload.cropRect.width,
- payload.cropRect.height,
- 0,
- 0,
- payload.outputWidth,
- payload.outputHeight
- );
- blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
+ blob = await createCroppedImageBlob(sourceImage, payload);
} catch {
alert(t("imageInput.cropFailed"));
return;
diff --git a/src/utils/__tests__/composerPricing.test.ts b/src/utils/__tests__/composerPricing.test.ts
index f2c923d3..f3ccbe98 100644
--- a/src/utils/__tests__/composerPricing.test.ts
+++ b/src/utils/__tests__/composerPricing.test.ts
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildPopiserverPriceQuoteRequest,
- buildPopiserverPointsEstimatePayload,
getQuotedPointAmount,
} from "@/utils/composerPricing";
import type { SelectedModel } from "@/types";
@@ -42,7 +41,7 @@ function context(overrides: Partial & { config?: Generat
describe("composerPricing", () => {
it("builds PopiServer estimate payloads with provider model aliases", () => {
- const payload = buildPopiserverPointsEstimatePayload(context({
+ const request = buildPopiserverPriceQuoteRequest(context({
inputVideoDurationSeconds: 12,
config: draft({
selectedModel: model({
@@ -56,7 +55,9 @@ describe("composerPricing", () => {
}),
}));
- expect(payload).toEqual({
+ expect(request).toEqual({
+ source: "estimate",
+ payload: {
model_name: "video-alias",
estimation_type: "auto",
parameters: {
@@ -66,6 +67,7 @@ describe("composerPricing", () => {
},
imageCount: 1,
input_video_duration_seconds: 12,
+ },
});
});
@@ -83,13 +85,19 @@ describe("composerPricing", () => {
});
it("uses PopiServer estimate only for billingMethod 2 or Seedance models", () => {
- expect(buildPopiserverPointsEstimatePayload(context({ config: draft({
+ expect(buildPopiserverPriceQuoteRequest(context({ config: draft({
selectedModel: model({ billingMethod: 2 }),
- }) }))).not.toBeNull();
- expect(buildPopiserverPointsEstimatePayload(context({ config: draft({
- selectedModel: model({ billingMethod: 1 }),
- }) }))).toBeNull();
- expect(buildPopiserverPointsEstimatePayload(context({
+ }) }))).toMatchObject({ source: "estimate" });
+ expect(buildPopiserverPriceQuoteRequest(context({ config: draft({
+ selectedModel: model({
+ billingMethod: 1,
+ aiModelId: 32,
+ aiModelCode: "image-code",
+ type: 1,
+ subType: 101,
+ }),
+ }) }))).toMatchObject({ source: "task-price" });
+ expect(buildPopiserverPriceQuoteRequest(context({
capability: "video",
config: draft({
selectedModel: model({
@@ -97,18 +105,19 @@ describe("composerPricing", () => {
billingMethod: 1,
}),
}),
- }))).not.toBeNull();
+ }))).toMatchObject({ source: "estimate" });
});
it("builds unified PopiServer quote requests", () => {
const estimateContext = context({ config: draft({
selectedModel: model({ billingMethod: 2 }),
}) });
- const pointsPayload = buildPopiserverPointsEstimatePayload(estimateContext);
- expect(buildPopiserverPriceQuoteRequest(estimateContext)).toEqual({
+ expect(buildPopiserverPriceQuoteRequest(estimateContext)).toMatchObject({
source: "estimate",
- payload: pointsPayload,
+ payload: {
+ estimation_type: "auto",
+ },
});
expect(buildPopiserverPriceQuoteRequest(context({ config: draft({
selectedModel: model({
diff --git a/src/utils/composerPricing.ts b/src/utils/composerPricing.ts
index f330b9d7..5cc6ad9e 100644
--- a/src/utils/composerPricing.ts
+++ b/src/utils/composerPricing.ts
@@ -84,7 +84,7 @@ function getPopiserverModelName(model: SelectedModel): string {
);
}
-export function buildPopiserverPointsEstimatePayload(
+function buildPopiserverPointsEstimatePayload(
context: ComposerPricingContext
): PopiserverPointsEstimatePayload | null {
const { config, capability, inputVideoDurationSeconds, media } = context;
@@ -142,7 +142,7 @@ export function getQuotedPointAmount(response: PopiserverPriceQuoteResponse): nu
return finiteNumberFromUnknown(response.data?.points);
}
-export function buildComposerPopiserverTaskPricePayload(
+function buildComposerPopiserverTaskPricePayload(
context: ComposerPricingContext
) {
const { config, capability, media } = context;