feat: add downloads for style pro and ai wash results
This commit is contained in:
parent
0f27bf1b10
commit
77174f4e21
@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Clock3,
|
Clock3,
|
||||||
|
Download,
|
||||||
History as HistoryIcon,
|
History as HistoryIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
@ -45,6 +46,7 @@ import {
|
|||||||
type AiWashControlLevel,
|
type AiWashControlLevel,
|
||||||
type AiWashPreset,
|
type AiWashPreset,
|
||||||
} from "@/lib/ai-wash/config";
|
} from "@/lib/ai-wash/config";
|
||||||
|
import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { imageGenerationService } from "@/services/canvas";
|
import { imageGenerationService } from "@/services/canvas";
|
||||||
@ -368,6 +370,11 @@ function getAiWashStatusLabel(status?: AiWashResultStatus) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAiWashDownloadName(result: AiWashResultCard) {
|
||||||
|
const createdAt = result.createdAt || Date.now();
|
||||||
|
return `ai-wash-${result.title || result.presetId || "result"}-${createdAt}`;
|
||||||
|
}
|
||||||
|
|
||||||
function composePresetPrompt(params: {
|
function composePresetPrompt(params: {
|
||||||
preset: AiWashPreset;
|
preset: AiWashPreset;
|
||||||
selectedLevelIndexByControl: Record<string, number>;
|
selectedLevelIndexByControl: Record<string, number>;
|
||||||
@ -456,6 +463,9 @@ export function AiWashWorkspace({
|
|||||||
useState<Record<string, number>>({});
|
useState<Record<string, number>>({});
|
||||||
const [historySourceOverride, setHistorySourceOverride] = useState("");
|
const [historySourceOverride, setHistorySourceOverride] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [downloadingResultId, setDownloadingResultId] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const [isHistoryExpanded, setIsHistoryExpanded] = useState(false);
|
const [isHistoryExpanded, setIsHistoryExpanded] = useState(false);
|
||||||
const [lastErrorMessage, setLastErrorMessage] = useState("");
|
const [lastErrorMessage, setLastErrorMessage] = useState("");
|
||||||
const [comparePositionById, setComparePositionById] = useState<
|
const [comparePositionById, setComparePositionById] = useState<
|
||||||
@ -977,6 +987,30 @@ export function AiWashWorkspace({
|
|||||||
});
|
});
|
||||||
}, [compareSourceUrl, onApplyResult, selectedResult]);
|
}, [compareSourceUrl, onApplyResult, selectedResult]);
|
||||||
|
|
||||||
|
const handleDownloadSelectedResult = useCallback(async () => {
|
||||||
|
if (!selectedResult?.imageUrl) {
|
||||||
|
toast.error("请先选择一张洗图结果");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadId = selectedResult.presetId || selectedResult.imageUrl;
|
||||||
|
setDownloadingResultId(downloadId);
|
||||||
|
try {
|
||||||
|
await downloadCanvasImageAsPng({
|
||||||
|
url: selectedResult.imageUrl,
|
||||||
|
name: buildAiWashDownloadName(selectedResult),
|
||||||
|
candidates: [selectedResult.imageUrl],
|
||||||
|
});
|
||||||
|
toast.success("图片已开始下载");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||||
|
} finally {
|
||||||
|
setDownloadingResultId((current) =>
|
||||||
|
current === downloadId ? null : current
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [selectedResult]);
|
||||||
|
|
||||||
const handleLoadHistoryBatch = useCallback((entry: AiWashHistoryBatch) => {
|
const handleLoadHistoryBatch = useCallback((entry: AiWashHistoryBatch) => {
|
||||||
const loadedPresetId =
|
const loadedPresetId =
|
||||||
(entry.items.find((item) => item.isDefault) || entry.items[0])?.presetId || "";
|
(entry.items.find((item) => item.isDefault) || entry.items[0])?.presetId || "";
|
||||||
@ -1430,6 +1464,25 @@ export function AiWashWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedResult?.imageUrl ? (
|
||||||
|
<button
|
||||||
|
className="absolute bottom-2 right-2 z-30 inline-flex h-10 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/92 px-4 text-xs font-medium text-[#333333] shadow-[0_10px_24px_rgba(0,0,0,0.07)] backdrop-blur transition hover:border-[#111111] hover:text-[#111111] disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
|
disabled={Boolean(downloadingResultId)}
|
||||||
|
onClick={() => {
|
||||||
|
void handleDownloadSelectedResult();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{downloadingResultId ===
|
||||||
|
(selectedResult.presetId || selectedResult.imageUrl) ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
下载
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="absolute bottom-2 left-2 z-30 inline-flex h-10 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/90 px-4 text-xs text-[#666666] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur transition hover:border-[#111111] hover:text-[#111111]"
|
className="absolute bottom-2 left-2 z-30 inline-flex h-10 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/90 px-4 text-xs text-[#666666] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur transition hover:border-[#111111] hover:text-[#111111]"
|
||||||
onClick={() => setIsHistoryExpanded((current) => !current)}
|
onClick={() => setIsHistoryExpanded((current) => !current)}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, Clock3, Loader2, LogOut, Sparkles, Upload } from "lucide-react";
|
import { ArrowLeft, Clock3, Download, Loader2, LogOut, Sparkles, Upload } from "lucide-react";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { signOut, useSession } from "next-auth/react";
|
||||||
import {
|
import {
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
@ -29,6 +29,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { imageGenerationService } from "@/services/canvas";
|
import { imageGenerationService } from "@/services/canvas";
|
||||||
|
import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download";
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
PublicStyleProCategory,
|
PublicStyleProCategory,
|
||||||
@ -457,6 +458,15 @@ function formatStyleProHistoryDateTime(value?: string | null) {
|
|||||||
return `${dateText} ${timeText}`;
|
return `${dateText} ${timeText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildStyleProDownloadName(item: StyleProHistoryItem) {
|
||||||
|
const mode =
|
||||||
|
item.generationMode === "realistic" ? "style-pro-realistic" : "style-pro";
|
||||||
|
const createdAt = item.createdAt
|
||||||
|
? item.createdAt.replace(/[T:.]/g, "-").slice(0, 19)
|
||||||
|
: String(Date.now());
|
||||||
|
return `${mode}-${createdAt}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatStyleProEstimatedWait(estimatedWaitMs?: number | null) {
|
function formatStyleProEstimatedWait(estimatedWaitMs?: number | null) {
|
||||||
const numeric = Number(estimatedWaitMs || 0);
|
const numeric = Number(estimatedWaitMs || 0);
|
||||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||||
@ -817,10 +827,16 @@ function GeneratingImagePlaceholder({
|
|||||||
function BeforeAfterSlider({
|
function BeforeAfterSlider({
|
||||||
afterUrl,
|
afterUrl,
|
||||||
beforeUrl,
|
beforeUrl,
|
||||||
|
downloadDisabled,
|
||||||
|
downloading,
|
||||||
|
onDownload,
|
||||||
taskState,
|
taskState,
|
||||||
}: {
|
}: {
|
||||||
afterUrl?: string | null;
|
afterUrl?: string | null;
|
||||||
beforeUrl?: string | null;
|
beforeUrl?: string | null;
|
||||||
|
downloadDisabled?: boolean;
|
||||||
|
downloading?: boolean;
|
||||||
|
onDownload?: () => void;
|
||||||
taskState?: StyleProTaskState | null;
|
taskState?: StyleProTaskState | null;
|
||||||
}) {
|
}) {
|
||||||
const showTaskState =
|
const showTaskState =
|
||||||
@ -946,6 +962,24 @@ function BeforeAfterSlider({
|
|||||||
<div className="absolute right-4 top-4 rounded-full bg-emerald-500/90 px-3 py-1 text-xs font-semibold text-black">
|
<div className="absolute right-4 top-4 rounded-full bg-emerald-500/90 px-3 py-1 text-xs font-semibold text-black">
|
||||||
结果
|
结果
|
||||||
</div>
|
</div>
|
||||||
|
{afterUrl ? (
|
||||||
|
<button
|
||||||
|
className="absolute bottom-4 right-4 z-20 inline-flex h-11 items-center gap-2 rounded-full border border-white/25 bg-black/72 px-4 text-[13px] font-semibold text-white shadow-[0_14px_32px_rgba(0,0,0,0.24)] backdrop-blur transition hover:bg-black disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
|
disabled={downloadDisabled || downloading}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDownload?.();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{downloading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
下载
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1021,6 +1055,9 @@ export function StyleProWorkspace({
|
|||||||
useState<StyleProGenerationMode>("style");
|
useState<StyleProGenerationMode>("style");
|
||||||
const [selectedModelId, setSelectedModelId] = useState("");
|
const [selectedModelId, setSelectedModelId] = useState("");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [downloadingImageId, setDownloadingImageId] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const [generationTaskState, setGenerationTaskState] =
|
const [generationTaskState, setGenerationTaskState] =
|
||||||
useState<StyleProTaskState | null>(null);
|
useState<StyleProTaskState | null>(null);
|
||||||
const generating =
|
const generating =
|
||||||
@ -1458,6 +1495,34 @@ export function StyleProWorkspace({
|
|||||||
[config]
|
[config]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDownloadResult = useCallback(
|
||||||
|
async (item?: StyleProHistoryItem | null) => {
|
||||||
|
const target = item || currentResult;
|
||||||
|
if (!target?.resultImageUrl) {
|
||||||
|
toast.error("暂无可下载的结果图");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadId = target.id || target.resultImageUrl;
|
||||||
|
setDownloadingImageId(downloadId);
|
||||||
|
try {
|
||||||
|
await downloadCanvasImageAsPng({
|
||||||
|
url: target.resultImageUrl,
|
||||||
|
name: buildStyleProDownloadName(target),
|
||||||
|
candidates: [target.resultImageUrl],
|
||||||
|
});
|
||||||
|
toast.success("图片已开始下载");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||||
|
} finally {
|
||||||
|
setDownloadingImageId((current) =>
|
||||||
|
current === downloadId ? null : current
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentResult]
|
||||||
|
);
|
||||||
|
|
||||||
const disabledReason = !sourceImageUrl
|
const disabledReason = !sourceImageUrl
|
||||||
? "请先上传图片"
|
? "请先上传图片"
|
||||||
: generationMode === "realistic" && config?.realisticModeAvailable === false
|
: generationMode === "realistic" && config?.realisticModeAvailable === false
|
||||||
@ -1875,6 +1940,15 @@ export function StyleProWorkspace({
|
|||||||
<BeforeAfterSlider
|
<BeforeAfterSlider
|
||||||
afterUrl={currentResult?.resultImageUrl}
|
afterUrl={currentResult?.resultImageUrl}
|
||||||
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
|
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
|
||||||
|
downloadDisabled={!currentResult?.resultImageUrl}
|
||||||
|
downloading={Boolean(
|
||||||
|
currentResult &&
|
||||||
|
downloadingImageId ===
|
||||||
|
(currentResult.id || currentResult.resultImageUrl)
|
||||||
|
)}
|
||||||
|
onDownload={() => {
|
||||||
|
void handleDownloadResult(currentResult);
|
||||||
|
}}
|
||||||
taskState={generationTaskState}
|
taskState={generationTaskState}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user