This commit is contained in:
@@ -150,6 +150,10 @@ export interface AiTaskStatus {
|
|||||||
type: "image" | "video";
|
type: "image" | "video";
|
||||||
status: "pending" | "running" | "completed" | "failed" | "cancelled";
|
status: "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||||
progress: number;
|
progress: number;
|
||||||
|
progressSource?: "real" | "estimated" | string | null;
|
||||||
|
stage?: string | null;
|
||||||
|
startedAt?: string | null;
|
||||||
|
expectedDurationMs?: number | null;
|
||||||
resultUrl: string | null;
|
resultUrl: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>;
|
||||||
@@ -514,7 +518,20 @@ export const aiGenerationClient = {
|
|||||||
|
|
||||||
subscribeTaskStatus(
|
subscribeTaskStatus(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
onUpdate: (task: Pick<AiTaskStatus, "taskId" | "status" | "progress" | "resultUrl" | "error">) => void,
|
onUpdate: (
|
||||||
|
task: Pick<
|
||||||
|
AiTaskStatus,
|
||||||
|
| "taskId"
|
||||||
|
| "status"
|
||||||
|
| "progress"
|
||||||
|
| "progressSource"
|
||||||
|
| "stage"
|
||||||
|
| "startedAt"
|
||||||
|
| "expectedDurationMs"
|
||||||
|
| "resultUrl"
|
||||||
|
| "error"
|
||||||
|
>,
|
||||||
|
) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
const url = buildApiUrl(`ai/tasks/${taskId}/stream`);
|
const url = buildApiUrl(`ai/tasks/${taskId}/stream`);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export interface TaskProgressEvent {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
status: string;
|
status: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
progressSource?: "real" | "estimated" | string | null;
|
||||||
|
stage?: string | null;
|
||||||
|
startedAt?: string | null;
|
||||||
|
expectedDurationMs?: number | null;
|
||||||
resultUrl?: string | null;
|
resultUrl?: string | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
@@ -37,7 +41,8 @@ export function waitForTask(
|
|||||||
operation: options.operation,
|
operation: options.operation,
|
||||||
});
|
});
|
||||||
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
|
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
|
||||||
const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
|
const noProgressTimeoutMs =
|
||||||
|
options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
|
||||||
const startedAt = options.startedAt ?? Date.now();
|
const startedAt = options.startedAt ?? Date.now();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -58,7 +63,10 @@ export function waitForTask(
|
|||||||
};
|
};
|
||||||
|
|
||||||
timeoutId = setTimeout(
|
timeoutId = setTimeout(
|
||||||
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
|
() =>
|
||||||
|
settle(() =>
|
||||||
|
reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))),
|
||||||
|
),
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -105,7 +113,11 @@ export function waitForTask(
|
|||||||
policy: { ...timeoutPolicy, noProgressTimeoutMs },
|
policy: { ...timeoutPolicy, noProgressTimeoutMs },
|
||||||
});
|
});
|
||||||
if (timeoutReason) {
|
if (timeoutReason) {
|
||||||
settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))));
|
settle(() =>
|
||||||
|
reject(
|
||||||
|
new Error(buildLocalTimeoutMessage(options.kind || "video")),
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -114,6 +126,10 @@ export function waitForTask(
|
|||||||
taskId,
|
taskId,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
progress: task.progress || 0,
|
progress: task.progress || 0,
|
||||||
|
progressSource: task.progressSource,
|
||||||
|
stage: task.stage,
|
||||||
|
startedAt: task.startedAt,
|
||||||
|
expectedDurationMs: task.expectedDurationMs,
|
||||||
resultUrl: task.resultUrl,
|
resultUrl: task.resultUrl,
|
||||||
error: task.error,
|
error: task.error,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ import { communityClient } from "../../api/communityClient";
|
|||||||
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
||||||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||||
|
import {
|
||||||
|
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
|
||||||
|
DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS,
|
||||||
|
} from "../../hooks/useSmoothedProgress";
|
||||||
import type { WebCanvasWorkflow } from "../../types";
|
import type { WebCanvasWorkflow } from "../../types";
|
||||||
import type { AssetLibraryCategory } from "../assets/localAssetStore";
|
import type { AssetLibraryCategory } from "../assets/localAssetStore";
|
||||||
import {
|
import {
|
||||||
@@ -4492,6 +4496,7 @@ function CanvasPage({
|
|||||||
progress={imageNodeProgress}
|
progress={imageNodeProgress}
|
||||||
status={imageTaskState?.status || "running"}
|
status={imageTaskState?.status || "running"}
|
||||||
message={imageTaskState?.message || "图片生成中"}
|
message={imageTaskState?.message || "图片生成中"}
|
||||||
|
expectedDurationMs={DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{imageNodeFocusActive && imageFocusSelectionReady ? (
|
{imageNodeFocusActive && imageFocusSelectionReady ? (
|
||||||
@@ -4866,6 +4871,7 @@ function CanvasPage({
|
|||||||
progress={videoNodeProgress}
|
progress={videoNodeProgress}
|
||||||
status={videoTaskState?.status || "running"}
|
status={videoTaskState?.status || "running"}
|
||||||
message={videoTaskState?.message || "视频生成中"}
|
message={videoTaskState?.message || "视频生成中"}
|
||||||
|
expectedDurationMs={DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "left", slot: "center" }, "studio-canvas-video-node__connector")}
|
{renderConnectorButton({ kind: "video", nodeId: videoNode.id, side: "left", slot: "center" }, "studio-canvas-video-node__connector")}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
import {
|
||||||
|
DEFAULT_GENERATION_EXPECTED_DURATION_MS,
|
||||||
|
useSmoothedProgress,
|
||||||
|
type ProgressSource,
|
||||||
|
} from "../../hooks/useSmoothedProgress";
|
||||||
import { canvasGenerationProgressStyle } from "./canvasUtils";
|
import { canvasGenerationProgressStyle } from "./canvasUtils";
|
||||||
|
|
||||||
type NodeGenStatus = "submitting" | "running" | "success" | "error";
|
type NodeGenStatus = "submitting" | "running" | "success" | "error";
|
||||||
@@ -7,10 +11,24 @@ interface CanvasSmoothedProgressRingProps {
|
|||||||
progress: number;
|
progress: number;
|
||||||
status: NodeGenStatus;
|
status: NodeGenStatus;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
progressSource?: ProgressSource;
|
||||||
|
startedAt?: number | string | Date | null;
|
||||||
|
expectedDurationMs?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasSmoothedProgressRing({ progress, status, message }: CanvasSmoothedProgressRingProps) {
|
export function CanvasSmoothedProgressRing({
|
||||||
const smoothed = useSmoothedProgress(progress, status);
|
progress,
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
progressSource = "estimated",
|
||||||
|
startedAt,
|
||||||
|
expectedDurationMs = DEFAULT_GENERATION_EXPECTED_DURATION_MS,
|
||||||
|
}: CanvasSmoothedProgressRingProps) {
|
||||||
|
const smoothed = useSmoothedProgress(progress, status, {
|
||||||
|
progressSource,
|
||||||
|
startedAt,
|
||||||
|
expectedDurationMs,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="studio-canvas-node-generation-progress"
|
className="studio-canvas-node-generation-progress"
|
||||||
@@ -18,7 +36,10 @@ export function CanvasSmoothedProgressRing({ progress, status, message }: Canvas
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
style={canvasGenerationProgressStyle(smoothed)}
|
style={canvasGenerationProgressStyle(smoothed)}
|
||||||
>
|
>
|
||||||
<span className="studio-canvas-node-generation-progress__ring" aria-hidden="true" />
|
<span
|
||||||
|
className="studio-canvas-node-generation-progress__ring"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<strong>{message}</strong>
|
<strong>{message}</strong>
|
||||||
<em>{smoothed}%</em>
|
<em>{smoothed}%</em>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -262,7 +262,17 @@ export async function waitForImageTaskResult(
|
|||||||
abortRef,
|
abortRef,
|
||||||
onProgress: (e) => {
|
onProgress: (e) => {
|
||||||
if (onStatus) {
|
if (onStatus) {
|
||||||
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
onStatus({
|
||||||
|
taskId,
|
||||||
|
status: e.status,
|
||||||
|
progress: e.progress,
|
||||||
|
progressSource: e.progressSource,
|
||||||
|
stage: e.stage,
|
||||||
|
startedAt: e.startedAt,
|
||||||
|
expectedDurationMs: e.expectedDurationMs,
|
||||||
|
resultUrl: e.resultUrl ?? undefined,
|
||||||
|
error: e.error ?? undefined,
|
||||||
|
} as AiTaskStatus);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -281,7 +291,17 @@ export async function waitForVideoTaskResult(
|
|||||||
abortRef,
|
abortRef,
|
||||||
onProgress: (e) => {
|
onProgress: (e) => {
|
||||||
if (onStatus) {
|
if (onStatus) {
|
||||||
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
onStatus({
|
||||||
|
taskId,
|
||||||
|
status: e.status,
|
||||||
|
progress: e.progress,
|
||||||
|
progressSource: e.progressSource,
|
||||||
|
stage: e.stage,
|
||||||
|
startedAt: e.startedAt,
|
||||||
|
expectedDurationMs: e.expectedDurationMs,
|
||||||
|
resultUrl: e.resultUrl ?? undefined,
|
||||||
|
error: e.error ?? undefined,
|
||||||
|
} as AiTaskStatus);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
import { useRef } from "react";
|
||||||
|
import {
|
||||||
|
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
|
||||||
|
formatEstimatedRemainingLabel,
|
||||||
|
useSmoothedProgress,
|
||||||
|
} from "../../hooks/useSmoothedProgress";
|
||||||
|
|
||||||
interface EcommerceProgressBarProps {
|
interface EcommerceProgressBarProps {
|
||||||
status: "idle" | "generating" | "done" | "failed" | string;
|
status: "idle" | "generating" | "done" | "failed" | string;
|
||||||
@@ -12,17 +17,40 @@ function mapStatus(status: string): "running" | "completed" | "failed" {
|
|||||||
return "running";
|
return "running";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EcommerceProgressBar({ status, label }: EcommerceProgressBarProps) {
|
export function EcommerceProgressBar({
|
||||||
const progress = mapStatus(status) === "running" ? 50 : 100;
|
status,
|
||||||
const smoothed = useSmoothedProgress(progress, mapStatus(status));
|
label,
|
||||||
|
}: EcommerceProgressBarProps) {
|
||||||
|
const startedAtRef = useRef(Date.now());
|
||||||
|
const mappedStatus = mapStatus(status);
|
||||||
|
const progress = mappedStatus === "running" ? 50 : 100;
|
||||||
|
const smoothed = useSmoothedProgress(progress, mappedStatus, {
|
||||||
|
progressSource: mappedStatus === "running" ? "estimated" : "real",
|
||||||
|
startedAt: startedAtRef.current,
|
||||||
|
expectedDurationMs: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
|
||||||
|
});
|
||||||
|
const remainingLabel =
|
||||||
|
mappedStatus === "running"
|
||||||
|
? formatEstimatedRemainingLabel({
|
||||||
|
nowMs: Date.now(),
|
||||||
|
startedAt: startedAtRef.current,
|
||||||
|
expectedDurationMs: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
if (status === "idle") return null;
|
if (status === "idle") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ecommerce-progress-bar">
|
<div className="ecommerce-progress-bar">
|
||||||
<span className="ecommerce-progress-bar__label">{label || "AI 正在生成"}</span>
|
<span className="ecommerce-progress-bar__label">
|
||||||
|
{label || "AI 正在生成"}
|
||||||
|
{remainingLabel ? ` / ${remainingLabel}` : ""}
|
||||||
|
</span>
|
||||||
<div className="ecommerce-progress-bar__track">
|
<div className="ecommerce-progress-bar__track">
|
||||||
<div className="ecommerce-progress-bar__fill" style={{ width: `${smoothed}%` }} />
|
<div
|
||||||
|
className="ecommerce-progress-bar__fill"
|
||||||
|
style={{ width: `${smoothed}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="ecommerce-progress-bar__value">{smoothed}%</span>
|
<span className="ecommerce-progress-bar__value">{smoothed}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
import {
|
||||||
|
DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
|
||||||
|
useSmoothedProgress,
|
||||||
|
type ProgressSource,
|
||||||
|
} from "../../hooks/useSmoothedProgress";
|
||||||
|
|
||||||
type MessageStatus = "thinking" | "completed" | "failed" | string;
|
type MessageStatus = "thinking" | "completed" | "failed" | string;
|
||||||
|
|
||||||
@@ -6,6 +10,9 @@ interface SmoothedProgressBarProps {
|
|||||||
progress: number;
|
progress: number;
|
||||||
status: MessageStatus;
|
status: MessageStatus;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
progressSource?: ProgressSource;
|
||||||
|
startedAt?: number | string | Date | null;
|
||||||
|
expectedDurationMs?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapMessageStatus(status: MessageStatus) {
|
function mapMessageStatus(status: MessageStatus) {
|
||||||
@@ -15,8 +22,19 @@ function mapMessageStatus(status: MessageStatus) {
|
|||||||
return "running" as const;
|
return "running" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SmoothedProgressBar({ progress, status, label }: SmoothedProgressBarProps) {
|
export function SmoothedProgressBar({
|
||||||
const smoothed = useSmoothedProgress(progress, mapMessageStatus(status));
|
progress,
|
||||||
|
status,
|
||||||
|
label,
|
||||||
|
progressSource = "estimated",
|
||||||
|
startedAt,
|
||||||
|
expectedDurationMs = DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
|
||||||
|
}: SmoothedProgressBarProps) {
|
||||||
|
const smoothed = useSmoothedProgress(progress, mapMessageStatus(status), {
|
||||||
|
progressSource,
|
||||||
|
startedAt,
|
||||||
|
expectedDurationMs,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span>{label || "超分处理中..."}</span>
|
<span>{label || "超分处理中..."}</span>
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ import { assetClient } from "../../../api/assetClient";
|
|||||||
import { communityClient } from "../../../api/communityClient";
|
import { communityClient } from "../../../api/communityClient";
|
||||||
import { saveAssetToLocalLibrary } from "../../assets/localAssetStore";
|
import { saveAssetToLocalLibrary } from "../../assets/localAssetStore";
|
||||||
import { SmoothedProgressBar } from "../SmoothedProgressBar";
|
import { SmoothedProgressBar } from "../SmoothedProgressBar";
|
||||||
import { useSmoothedProgress } from "../../../hooks/useSmoothedProgress";
|
import {
|
||||||
|
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS,
|
||||||
|
DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS,
|
||||||
|
DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS,
|
||||||
|
formatEstimatedRemainingLabel,
|
||||||
|
useSmoothedProgress,
|
||||||
|
} from "../../../hooks/useSmoothedProgress";
|
||||||
import { renderMarkdownBlocks } from "../markdownRenderer";
|
import { renderMarkdownBlocks } from "../markdownRenderer";
|
||||||
import { downloadResultAsset } from "../workbenchDownload";
|
import { downloadResultAsset } from "../workbenchDownload";
|
||||||
import type { WorkbenchChatAttachment, WorkbenchChatMessage, WorkbenchResultActionPayload } from "../workbenchChatTypes";
|
import type { WorkbenchChatAttachment, WorkbenchChatMessage, WorkbenchResultActionPayload } from "../workbenchChatTypes";
|
||||||
@@ -456,6 +462,8 @@ export const ResultCard = memo(function ResultCard({
|
|||||||
progress={message.taskProgress ?? 18}
|
progress={message.taskProgress ?? 18}
|
||||||
status={message.status || "thinking"}
|
status={message.status || "thinking"}
|
||||||
label={message.taskStatusLabel || "超分处理中..."}
|
label={message.taskStatusLabel || "超分处理中..."}
|
||||||
|
progressSource="estimated"
|
||||||
|
expectedDurationMs={DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -575,7 +583,23 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({
|
|||||||
const specs = message.result?.specs || [];
|
const specs = message.result?.specs || [];
|
||||||
const prompt = message.prompt || message.body;
|
const prompt = message.prompt || message.body;
|
||||||
const isVideo = message.mode === "video";
|
const isVideo = message.mode === "video";
|
||||||
const smoothed = useSmoothedProgress(message.taskProgress ?? 5, message.status === "thinking" ? "running" : "completed");
|
const expectedDurationMs = isVideo
|
||||||
|
? DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS
|
||||||
|
: DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS;
|
||||||
|
const progressStatus = message.status === "thinking" ? "running" : "completed";
|
||||||
|
const smoothed = useSmoothedProgress(message.taskProgress ?? 5, progressStatus, {
|
||||||
|
progressSource: progressStatus === "running" ? "estimated" : "real",
|
||||||
|
startedAt: message.createdAt,
|
||||||
|
expectedDurationMs,
|
||||||
|
});
|
||||||
|
const remainingLabel = progressStatus === "running"
|
||||||
|
? formatEstimatedRemainingLabel({
|
||||||
|
nowMs: Date.now(),
|
||||||
|
startedAt: message.createdAt,
|
||||||
|
expectedDurationMs,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const statusLabel = message.taskStatusLabel || (isVideo ? "视频生成中" : "图片生成中");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`ai-generation-pending-card${isVideo ? " is-video" : " is-image"}`}>
|
<div className={`ai-generation-pending-card${isVideo ? " is-video" : " is-image"}`}>
|
||||||
@@ -590,7 +614,7 @@ export const GenerationPendingCard = memo(function GenerationPendingCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="ai-generation-pending-card__meta">
|
<div className="ai-generation-pending-card__meta">
|
||||||
<div>
|
<div>
|
||||||
<strong>{message.taskStatusLabel || "Generating..."}</strong>
|
<strong>{remainingLabel ? `${statusLabel} / ${remainingLabel}` : statusLabel}</strong>
|
||||||
<span>{prompt}</span>
|
<span>{prompt}</span>
|
||||||
</div>
|
</div>
|
||||||
{specs.length > 0 && (
|
{specs.length > 0 && (
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "../test/testHarness";
|
||||||
|
|
||||||
|
import {
|
||||||
|
calculateEstimatedProgress,
|
||||||
|
formatEstimatedRemainingLabel,
|
||||||
|
resolveProgressStartedAt,
|
||||||
|
} from "./useSmoothedProgress";
|
||||||
|
|
||||||
|
describe("useSmoothedProgress helpers", () => {
|
||||||
|
it("calculates estimated progress from elapsed time with an easing curve", () => {
|
||||||
|
expect(
|
||||||
|
Math.round(
|
||||||
|
calculateEstimatedProgress({
|
||||||
|
nowMs: 220_000,
|
||||||
|
startedAtMs: 100_000,
|
||||||
|
expectedDurationMs: 120_000,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(85);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps estimated progress below completion", () => {
|
||||||
|
expect(
|
||||||
|
Math.round(
|
||||||
|
calculateEstimatedProgress({
|
||||||
|
nowMs: 900_000,
|
||||||
|
startedAtMs: 0,
|
||||||
|
expectedDurationMs: 120_000,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(92);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses local workbench timestamps", () => {
|
||||||
|
expect(resolveProgressStartedAt("2026-06-10 09:30")).toBe(
|
||||||
|
new Date(2026, 5, 10, 9, 30).getTime(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats remaining time for estimated tasks", () => {
|
||||||
|
expect(
|
||||||
|
formatEstimatedRemainingLabel({
|
||||||
|
nowMs: new Date(2026, 5, 10, 9, 30).getTime(),
|
||||||
|
startedAt: "2026-06-10 09:29",
|
||||||
|
expectedDurationMs: 120_000,
|
||||||
|
}),
|
||||||
|
).toBe("预计还需 1 分钟");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
type ProgressStatus = "queued" | "running" | "submitting" | "completed" | "failed" | "success" | "error";
|
type ProgressStatus =
|
||||||
|
| "queued"
|
||||||
|
| "running"
|
||||||
|
| "submitting"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "success"
|
||||||
|
| "error";
|
||||||
|
export type ProgressSource = "real" | "estimated";
|
||||||
|
|
||||||
interface SmoothedProgressOptions {
|
interface SmoothedProgressOptions {
|
||||||
creepSpeed?: number;
|
creepSpeed?: number;
|
||||||
@@ -8,6 +16,11 @@ interface SmoothedProgressOptions {
|
|||||||
creepCeiling?: number;
|
creepCeiling?: number;
|
||||||
creepAhead?: number;
|
creepAhead?: number;
|
||||||
completionDuration?: number;
|
completionDuration?: number;
|
||||||
|
progressSource?: ProgressSource;
|
||||||
|
startedAt?: number | string | Date | null;
|
||||||
|
expectedDurationMs?: number | null;
|
||||||
|
estimatedFloor?: number;
|
||||||
|
estimatedCeiling?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CREEP_SPEED = 0.5;
|
const DEFAULT_CREEP_SPEED = 0.5;
|
||||||
@@ -15,15 +28,109 @@ const DEFAULT_CHASE_RATE = 0.09;
|
|||||||
const DEFAULT_CREEP_CEILING = 97;
|
const DEFAULT_CREEP_CEILING = 97;
|
||||||
const DEFAULT_CREEP_AHEAD = 25;
|
const DEFAULT_CREEP_AHEAD = 25;
|
||||||
const DEFAULT_COMPLETION_DURATION = 450;
|
const DEFAULT_COMPLETION_DURATION = 450;
|
||||||
|
export const DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS = 120_000;
|
||||||
|
export const DEFAULT_VIDEO_GENERATION_EXPECTED_DURATION_MS = 240_000;
|
||||||
|
export const DEFAULT_SUPER_RESOLUTION_EXPECTED_DURATION_MS = 180_000;
|
||||||
|
export const DEFAULT_GENERATION_EXPECTED_DURATION_MS =
|
||||||
|
DEFAULT_IMAGE_GENERATION_EXPECTED_DURATION_MS;
|
||||||
|
const DEFAULT_ESTIMATED_FLOOR = 12;
|
||||||
|
const DEFAULT_ESTIMATED_CEILING = 92;
|
||||||
|
const ESTIMATED_PROGRESS_CURVE = 2.4;
|
||||||
|
const MIN_EXPECTED_DURATION_MS = 1_000;
|
||||||
|
|
||||||
function isTerminal(status: ProgressStatus): boolean {
|
function isTerminal(status: ProgressStatus): boolean {
|
||||||
return status === "completed" || status === "success" || status === "failed" || status === "error";
|
return (
|
||||||
|
status === "completed" ||
|
||||||
|
status === "success" ||
|
||||||
|
status === "failed" ||
|
||||||
|
status === "error"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSuccess(status: ProgressStatus): boolean {
|
function isSuccess(status: ProgressStatus): boolean {
|
||||||
return status === "completed" || status === "success";
|
return status === "completed" || status === "success";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveProgressStartedAt(
|
||||||
|
value: number | string | Date | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
const time = value.getTime();
|
||||||
|
return Number.isFinite(time) ? time : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== "string" || !value.trim()) return null;
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
const localMatch = normalized.match(
|
||||||
|
/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?/,
|
||||||
|
);
|
||||||
|
if (localMatch) {
|
||||||
|
const [, year, month, day, hours, minutes, seconds] = localMatch;
|
||||||
|
const time = new Date(
|
||||||
|
Number(year),
|
||||||
|
Number(month) - 1,
|
||||||
|
Number(day),
|
||||||
|
Number(hours),
|
||||||
|
Number(minutes),
|
||||||
|
Number(seconds || 0),
|
||||||
|
).getTime();
|
||||||
|
return Number.isFinite(time) ? time : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Date.parse(normalized);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateEstimatedProgress(input: {
|
||||||
|
nowMs: number;
|
||||||
|
startedAtMs: number;
|
||||||
|
expectedDurationMs?: number | null;
|
||||||
|
floor?: number;
|
||||||
|
ceiling?: number;
|
||||||
|
}): number {
|
||||||
|
const floor = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(99, input.floor ?? DEFAULT_ESTIMATED_FLOOR),
|
||||||
|
);
|
||||||
|
const ceiling = Math.max(
|
||||||
|
floor,
|
||||||
|
Math.min(99, input.ceiling ?? DEFAULT_ESTIMATED_CEILING),
|
||||||
|
);
|
||||||
|
const duration = Math.max(
|
||||||
|
MIN_EXPECTED_DURATION_MS,
|
||||||
|
Number(input.expectedDurationMs) || DEFAULT_GENERATION_EXPECTED_DURATION_MS,
|
||||||
|
);
|
||||||
|
const elapsed = Math.max(0, input.nowMs - input.startedAtMs);
|
||||||
|
const ratio = elapsed / duration;
|
||||||
|
const eased = 1 - Math.exp(-ratio * ESTIMATED_PROGRESS_CURVE);
|
||||||
|
return Math.min(ceiling, floor + eased * (ceiling - floor));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEstimatedRemainingLabel(input: {
|
||||||
|
nowMs: number;
|
||||||
|
startedAt: number | string | Date | null | undefined;
|
||||||
|
expectedDurationMs?: number | null;
|
||||||
|
}): string | null {
|
||||||
|
const startedAtMs = resolveProgressStartedAt(input.startedAt);
|
||||||
|
if (!startedAtMs) return null;
|
||||||
|
const duration = Math.max(
|
||||||
|
MIN_EXPECTED_DURATION_MS,
|
||||||
|
Number(input.expectedDurationMs) || DEFAULT_GENERATION_EXPECTED_DURATION_MS,
|
||||||
|
);
|
||||||
|
const remainingSeconds = Math.max(
|
||||||
|
0,
|
||||||
|
Math.ceil((startedAtMs + duration - input.nowMs) / 1000),
|
||||||
|
);
|
||||||
|
if (remainingSeconds <= 0) return "即将完成";
|
||||||
|
if (remainingSeconds < 60) return `预计还需 ${remainingSeconds} 秒`;
|
||||||
|
return `预计还需 ${Math.ceil(remainingSeconds / 60)} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Smoothly interpolates between coarse server-reported progress values.
|
* Smoothly interpolates between coarse server-reported progress values.
|
||||||
* On completion, animates quickly to 100% instead of jumping.
|
* On completion, animates quickly to 100% instead of jumping.
|
||||||
@@ -37,24 +144,59 @@ export function useSmoothedProgress(
|
|||||||
const chaseRate = options?.chaseRate ?? DEFAULT_CHASE_RATE;
|
const chaseRate = options?.chaseRate ?? DEFAULT_CHASE_RATE;
|
||||||
const creepCeiling = options?.creepCeiling ?? DEFAULT_CREEP_CEILING;
|
const creepCeiling = options?.creepCeiling ?? DEFAULT_CREEP_CEILING;
|
||||||
const creepAhead = options?.creepAhead ?? DEFAULT_CREEP_AHEAD;
|
const creepAhead = options?.creepAhead ?? DEFAULT_CREEP_AHEAD;
|
||||||
const completionDuration = options?.completionDuration ?? DEFAULT_COMPLETION_DURATION;
|
const completionDuration =
|
||||||
|
options?.completionDuration ?? DEFAULT_COMPLETION_DURATION;
|
||||||
|
const progressSource = options?.progressSource ?? "real";
|
||||||
|
const startedAt = options?.startedAt;
|
||||||
|
const expectedDurationMs =
|
||||||
|
options?.expectedDurationMs ?? DEFAULT_GENERATION_EXPECTED_DURATION_MS;
|
||||||
|
const estimatedFloor = options?.estimatedFloor ?? DEFAULT_ESTIMATED_FLOOR;
|
||||||
|
const estimatedCeiling =
|
||||||
|
options?.estimatedCeiling ?? DEFAULT_ESTIMATED_CEILING;
|
||||||
|
|
||||||
const [displayed, setDisplayed] = useState(0);
|
const [displayed, setDisplayed] = useState(0);
|
||||||
const rafRef = useRef(0);
|
const rafRef = useRef(0);
|
||||||
const targetRef = useRef(realProgress);
|
const targetRef = useRef(realProgress);
|
||||||
const statusRef = useRef(status);
|
const statusRef = useRef(status);
|
||||||
|
const progressSourceRef = useRef(progressSource);
|
||||||
|
const expectedDurationMsRef = useRef(expectedDurationMs);
|
||||||
|
const estimatedFloorRef = useRef(estimatedFloor);
|
||||||
|
const estimatedCeilingRef = useRef(estimatedCeiling);
|
||||||
|
const estimatedStartedAtRef = useRef<number | null>(null);
|
||||||
const completionStartRef = useRef<number | null>(null);
|
const completionStartRef = useRef<number | null>(null);
|
||||||
const completionBaseRef = useRef(0);
|
const completionBaseRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
targetRef.current = realProgress;
|
targetRef.current = realProgress;
|
||||||
statusRef.current = status;
|
statusRef.current = status;
|
||||||
|
progressSourceRef.current = progressSource;
|
||||||
|
expectedDurationMsRef.current = expectedDurationMs;
|
||||||
|
estimatedFloorRef.current = estimatedFloor;
|
||||||
|
estimatedCeilingRef.current = estimatedCeiling;
|
||||||
|
|
||||||
|
if (progressSource === "estimated" && !isTerminal(status)) {
|
||||||
|
estimatedStartedAtRef.current =
|
||||||
|
resolveProgressStartedAt(startedAt) ??
|
||||||
|
estimatedStartedAtRef.current ??
|
||||||
|
Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
if (isSuccess(status) && completionStartRef.current === null) {
|
if (isSuccess(status) && completionStartRef.current === null) {
|
||||||
completionStartRef.current = performance.now();
|
completionStartRef.current = performance.now();
|
||||||
completionBaseRef.current = displayed;
|
completionBaseRef.current = displayed;
|
||||||
|
} else if (!isSuccess(status)) {
|
||||||
|
completionStartRef.current = null;
|
||||||
}
|
}
|
||||||
}, [realProgress, status]);
|
}, [
|
||||||
|
displayed,
|
||||||
|
estimatedCeiling,
|
||||||
|
estimatedFloor,
|
||||||
|
expectedDurationMs,
|
||||||
|
progressSource,
|
||||||
|
realProgress,
|
||||||
|
startedAt,
|
||||||
|
status,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "failed" || status === "error") {
|
if (status === "failed" || status === "error") {
|
||||||
@@ -82,6 +224,20 @@ export function useSmoothedProgress(
|
|||||||
|
|
||||||
if (isTerminal(currentStatus)) return current;
|
if (isTerminal(currentStatus)) return current;
|
||||||
|
|
||||||
|
if (progressSourceRef.current === "estimated") {
|
||||||
|
const target = calculateEstimatedProgress({
|
||||||
|
nowMs: Date.now(),
|
||||||
|
startedAtMs: estimatedStartedAtRef.current ?? Date.now(),
|
||||||
|
expectedDurationMs: expectedDurationMsRef.current,
|
||||||
|
floor: estimatedFloorRef.current,
|
||||||
|
ceiling: estimatedCeilingRef.current,
|
||||||
|
});
|
||||||
|
if (current >= target) return current;
|
||||||
|
const gap = target - current;
|
||||||
|
const step = (gap * chaseRate + 0.2) * dt * 60;
|
||||||
|
return Math.min(current + step, target);
|
||||||
|
}
|
||||||
|
|
||||||
const target = targetRef.current;
|
const target = targetRef.current;
|
||||||
|
|
||||||
if (current >= target) {
|
if (current >= target) {
|
||||||
@@ -100,7 +256,14 @@ export function useSmoothedProgress(
|
|||||||
|
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
return () => cancelAnimationFrame(rafRef.current);
|
return () => cancelAnimationFrame(rafRef.current);
|
||||||
}, [status, chaseRate, creepSpeed, creepCeiling, creepAhead, completionDuration]);
|
}, [
|
||||||
|
status,
|
||||||
|
chaseRate,
|
||||||
|
creepSpeed,
|
||||||
|
creepCeiling,
|
||||||
|
creepAhead,
|
||||||
|
completionDuration,
|
||||||
|
]);
|
||||||
|
|
||||||
if (status === "failed" || status === "error") return Math.round(displayed);
|
if (status === "failed" || status === "error") return Math.round(displayed);
|
||||||
if (isSuccess(status) && displayed >= 99.5) return 100;
|
if (isSuccess(status) && displayed >= 99.5) return 100;
|
||||||
|
|||||||
Reference in New Issue
Block a user