fix(*): 视频计费与结果解析 - 按 Token 预估展示并解析数组媒体结果
This commit is contained in:
@@ -441,9 +441,11 @@ func TestNewAPIVideoGenerationsParsesNestedVideoResults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload string
|
||||
wantURL string
|
||||
}{
|
||||
{name: "channel result URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","result_url":"https://cdn.example/channel-result.mp4"}}`},
|
||||
{name: "provider nested video URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","data":{"status":"completed","video_url":"https://cdn.example/provider-result.mp4"}}}`},
|
||||
{name: "channel result URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","result_url":"https://cdn.example/channel-result.mp4"}}`, wantURL: "https://cdn.example/channel-result.mp4"},
|
||||
{name: "provider nested video URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","data":{"status":"completed","video_url":"https://cdn.example/provider-result.mp4"}}}`, wantURL: "https://cdn.example/provider-result.mp4"},
|
||||
{name: "provider data array URL", payload: `{"created":1789773326,"data":[{"url":"https://cdn.example/seedance-result.mp4?preview=1"}],"id":"task-upstream","object":"video.generation","status":"completed","usage":{"completion_tokens":108872,"total_tokens":108872}}`, wantURL: "https://cdn.example/seedance-result.mp4?preview=1"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -454,8 +456,8 @@ func TestNewAPIVideoGenerationsParsesNestedVideoResults(t *testing.T) {
|
||||
if state.Status != StatusSucceeded || state.Result == nil || len(state.Result.Videos) != 1 {
|
||||
t.Fatalf("state = %#v, want one completed video", state)
|
||||
}
|
||||
if state.Result.Videos[0].URL == "" {
|
||||
t.Fatalf("video = %#v, want a result URL", state.Result.Videos[0])
|
||||
if state.Result.Videos[0].URL != test.wantURL {
|
||||
t.Fatalf("video = %#v, want URL %q", state.Result.Videos[0], test.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -67,7 +67,7 @@
|
||||
| `response.taskId` | `{"$coalesce":[{"$ref":"response.data.task_id"},{"$ref":"response.data.taskId"},{"$ref":"response.task_id"},{"$ref":"response.taskId"},{"$ref":"response.data.id"},{"$ref":"response.id"},{"$ref":"taskId"}]}` |
|
||||
| `response.status` | `{"$coalesce":[{"$ref":"response.status"},{"$ref":"response.state"},{"$ref":"response.data.status"},"pending"]}` |
|
||||
| `response.message` | `{"$coalesce":[{"$ref":"response.error.message"},{"$ref":"response.message"},{"$ref":"response.fail_reason"}]}` |
|
||||
| `response.videos` | `{"$coalesce":[{"$ref":"response.data.result_url"},{"$ref":"response.data.video_url"},{"$ref":"response.data.output_url"},{"$ref":"response.data.url"},{"$ref":"response.data.metadata.url"},{"$ref":"response.data.data.video_url"},{"$ref":"response.data.data.output_url"},{"$ref":"response.data.data.result_url"},{"$ref":"response.data.data.url"},{"$ref":"response.data.data.metadata.url"},{"$ref":"response.video_url"},{"$ref":"response.videoUrl"},{"$ref":"response.result_url"},{"$ref":"response.output_url"},{"$ref":"response.url"},{"$ref":"response.metadata.url"},{"$ref":"response.output.url"}]}` |
|
||||
| `response.videos` | `{"$coalesce":[{"$map":{"from":{"$ref":"response.data"},"as":"item","in":{"$coalesce":[{"$ref":"item.url"},{"$ref":"item.video_url"},{"$ref":"item.videoUrl"},{"$ref":"item.output_url"},{"$ref":"item.outputUrl"}]}}},{"$ref":"response.data.result_url"},{"$ref":"response.data.video_url"},{"$ref":"response.data.output_url"},{"$ref":"response.data.url"},{"$ref":"response.data.metadata.url"},{"$ref":"response.data.data.video_url"},{"$ref":"response.data.data.output_url"},{"$ref":"response.data.data.result_url"},{"$ref":"response.data.data.url"},{"$ref":"response.data.data.metadata.url"},{"$ref":"response.video_url"},{"$ref":"response.videoUrl"},{"$ref":"response.result_url"},{"$ref":"response.output_url"},{"$ref":"response.url"},{"$ref":"response.metadata.url"},{"$ref":"response.output.url"}]}` |
|
||||
| `response.errorPaths[0]` | `"error.code"` |
|
||||
| `response.resultEphemeral` | `true` |
|
||||
|
||||
@@ -345,6 +345,33 @@
|
||||
},
|
||||
"videos": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$map": {
|
||||
"from": {
|
||||
"$ref": "response.data"
|
||||
},
|
||||
"as": "item",
|
||||
"in": {
|
||||
"$coalesce": [
|
||||
{
|
||||
"$ref": "item.url"
|
||||
},
|
||||
{
|
||||
"$ref": "item.video_url"
|
||||
},
|
||||
{
|
||||
"$ref": "item.videoUrl"
|
||||
},
|
||||
{
|
||||
"$ref": "item.output_url"
|
||||
},
|
||||
{
|
||||
"$ref": "item.outputUrl"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "response.data.result_url"
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -337,7 +337,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
const renderSubmitButton = (expanded: boolean) => {
|
||||
const showCost = creditsEnabled && credits !== null;
|
||||
const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
const actionLabel = isRunning ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成";
|
||||
const actionLabel = isRunning ? "生成中" : showCost ? `${routeQuote?.estimated ? "预估" : "消耗"} ${formattedCredits} 积分,生成` : "生成";
|
||||
return (
|
||||
<Button
|
||||
type="text"
|
||||
@@ -357,7 +357,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
|
||||
{showCost ? (
|
||||
<span className="canvas-node-composer-submit-cost">
|
||||
<CreditSymbol />
|
||||
<span>{routeQuote?.estimated ? "预计 " : ""}{formattedCredits}</span>
|
||||
<span>{routeQuote?.estimated ? `预估:${formattedCredits}` : formattedCredits}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="canvas-node-composer-submit-action" aria-hidden>
|
||||
|
||||
@@ -506,7 +506,7 @@ function tierSpecificationLabel(tier: NonNullable<NonNullable<AiConfig["channels
|
||||
function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice | null | undefined; quote?: LogicalModelQuote; compact?: boolean }) {
|
||||
if (quote) {
|
||||
const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
const label = quote.estimated ? `预计 ${amount}` : `${amount}`;
|
||||
const label = quote.estimated ? `预估:${amount}` : `${amount}`;
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={modelQuoteDescription(quote)}>
|
||||
<Coins className="size-3" />
|
||||
|
||||
@@ -33,7 +33,9 @@ import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { buildImageResolutionOptions, formatImageResolutionSize, supportsImageResolutionPresets } from "@/lib/image-resolution-tiers";
|
||||
import { modelCapabilityConfigFor, normalizeVideoValue, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { modelQuoteDescription, modelQuoteRequest } from "@/lib/model-pricing";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
|
||||
import { resolveResourceUrl } from "@/services/api/resources";
|
||||
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
@@ -367,6 +369,8 @@ export function CreationComposer(props: ComposerProps) {
|
||||
const canSubmit = Boolean(props.prompt.trim()) && !interactionBusy;
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const priceChannel = resolveModelChannel(props.config, props.model);
|
||||
const quoteRequest = useMemo(() => modelQuoteRequest(props.config, props.model, props.mode, props.modelRequirements), [props.config, props.mode, props.model, props.modelRequirements]);
|
||||
const [routeQuote, setRouteQuote] = useState<LogicalModelQuote | null>(null);
|
||||
const canOptimizePrompt = Boolean(props.promptOptimizerProvider) && (props.mode === "image" || props.mode === "video");
|
||||
const optimizerReferences = props.references.filter((reference) => reference.active && reference.kind !== "skill");
|
||||
const credits = requestCreditCost({
|
||||
@@ -375,10 +379,28 @@ export function CreationComposer(props: ComposerProps) {
|
||||
model: modelOptionName(props.model),
|
||||
count: props.mode === "image" ? props.count : 1,
|
||||
seconds: props.mode === "video" ? props.seconds : 1,
|
||||
capability: props.mode,
|
||||
config: props.config,
|
||||
requirements: props.modelRequirements,
|
||||
});
|
||||
const showCost = creditsEnabled && credits !== null;
|
||||
const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
const actionLabel = props.referenceReplacementBusy ? "正在替换参考图" : interactionBusy || (props.generationActive && !canSubmit) ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送";
|
||||
useEffect(() => {
|
||||
if (!creditsEnabled || !quoteRequest) {
|
||||
setRouteQuote(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setRouteQuote(null);
|
||||
quoteModel(quoteRequest, controller.signal)
|
||||
.then(({ quote }) => setRouteQuote(quote))
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setRouteQuote(null);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [creditsEnabled, quoteRequest]);
|
||||
const generationCredits = routeQuote ? routeQuote.amountMicrocredits / 1_000_000 : credits;
|
||||
const showCost = creditsEnabled && generationCredits !== null && generationCredits !== undefined;
|
||||
const formattedCredits = generationCredits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
const actionLabel = props.referenceReplacementBusy ? "正在替换参考图" : interactionBusy || (props.generationActive && !canSubmit) ? "生成中" : showCost ? `${routeQuote?.estimated ? "预估" : "消耗"} ${formattedCredits} 积分,发送` : "发送";
|
||||
// Send-button working state must span the WHOLE generation (not just the
|
||||
// submit-lock window): spinner + glow stay while a message is pending and
|
||||
// the composer is empty; typing a next prompt returns the arrow so the
|
||||
@@ -610,7 +632,7 @@ export function CreationComposer(props: ComposerProps) {
|
||||
title={!canSubmit && !interactionBusy ? "输入创作想法后即可生成" : actionLabel}
|
||||
>
|
||||
{showWorkingGlow ? <WorkingGlow active color="var(--creation-text)" radius="999px" /> : null}
|
||||
{showCost ? <span className="creation-submit-cost"><CreditSymbol /><span>{formattedCredits}</span></span> : null}
|
||||
{showCost ? <span className="creation-submit-cost" title={routeQuote ? modelQuoteDescription(routeQuote) : undefined}><CreditSymbol /><span>{routeQuote?.estimated ? `预估:${formattedCredits}` : formattedCredits}</span></span> : null}
|
||||
<span className="creation-submit-action" aria-hidden>{showWorkingSpinner ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}<span>{showWorkingSpinner ? "生成中" : "开始创作"}</span></span>
|
||||
</Button>
|
||||
</footer>
|
||||
|
||||
@@ -545,7 +545,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
</div>
|
||||
<footer className="workflow-editor-actions">
|
||||
<div className="workflow-generation-cost" aria-live="polite">
|
||||
{creditsEnabled && formattedGenerationCredits ? <><CreditSymbol /><span title={routeQuote ? modelQuoteDescription(routeQuote) : undefined}>本次预计 {formattedGenerationCredits} 积分</span></> : creditsEnabled && routedModel ? <span>本次费用将在提交时按实际规格计算</span> : null}
|
||||
{creditsEnabled && formattedGenerationCredits ? <><CreditSymbol /><span title={routeQuote ? modelQuoteDescription(routeQuote) : undefined}>本次{routeQuote?.estimated ? "预估" : "费用"} {formattedGenerationCredits} 积分</span></> : creditsEnabled && routedModel ? <span>本次费用将在提交时按实际规格计算</span> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2"><Button danger icon={<Trash2 className="size-4" />} loading={deleteShot.isPending} disabled={saveShot.isPending || selectedShotSubmitting || changeAssetBinding.isPending} onClick={requestDeleteShot}>删除镜头</Button><Button htmlType="submit" icon={<Save className="size-4" />} loading={saveShot.isPending} disabled={!editorDirty || deleteShot.isPending}>保存脚本</Button><Button type="primary" icon={<Play className="size-4" />} loading={selectedShotSubmitting || shotTask?.status === "queued" || shotTask?.status === "running"} disabled={deleteShot.isPending} onClick={() => void generateArtifact()}>{selectedShotSubmitting ? `${stageCopy.action}(正在提交)` : shotTask?.status === "queued" || shotTask?.status === "running" ? `${stageCopy.action}(已运行${shotTaskElapsed})` : shotTask?.status === "failed" ? `${stageCopy.action}(上次失败,可重试)` : shotTask?.status === "succeeded" && !newestArtifact ? `${stageCopy.action}(已完成,正在同步)` : newestArtifact ? `${stageCopy.action}(已生成)` : stageCopy.action}</Button></div>
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user