feat(*): 创作工作台 - 完善画布生成、模型定价与创建流程
This commit is contained in:
@@ -538,9 +538,9 @@ export function AgentReasoningFeed({ items, theme }: { items: CloudAgentChatMess
|
||||
|
||||
/**
|
||||
* Agent SSE events contain text chunks. Keep the full text in the message
|
||||
* state, but reveal one code point at a time so a chunk never appears as a
|
||||
* whole block. The loop continues briefly after the stream ends to drain any
|
||||
* text that was buffered by the network.
|
||||
* state, but reveal it in small batches. A fixed one-code-point delay lets a
|
||||
* fast stream build an ever-growing queue, so the batch grows with the
|
||||
* backlog and eventually catches up to the latest text in one render.
|
||||
*/
|
||||
function useTypewriterText(targetText: string, shouldAnimate: boolean) {
|
||||
const targetRef = useRef(targetText);
|
||||
@@ -568,16 +568,29 @@ function useTypewriterText(targetText: string, shouldAnimate: boolean) {
|
||||
}
|
||||
|
||||
const currentCharacters = Array.from(visibleRef.current);
|
||||
if (currentCharacters.length >= targetCharacters.length) {
|
||||
const pendingCharacters = targetCharacters.length - currentCharacters.length;
|
||||
if (pendingCharacters <= 0) {
|
||||
runningRef.current = false;
|
||||
timerRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextText = targetCharacters.slice(0, currentCharacters.length + 1).join("");
|
||||
// Normal output remains visibly animated. Once stream chunks start
|
||||
// outrunning the renderer, increase the batch instead of queuing
|
||||
// every code point behind a fixed 16ms delay.
|
||||
const revealCount = pendingCharacters > 384
|
||||
? pendingCharacters
|
||||
: pendingCharacters > 192
|
||||
? 32
|
||||
: pendingCharacters > 96
|
||||
? 16
|
||||
: pendingCharacters > 24
|
||||
? 8
|
||||
: 3;
|
||||
const nextText = targetCharacters.slice(0, currentCharacters.length + revealCount).join("");
|
||||
visibleRef.current = nextText;
|
||||
setVisibleText(nextText);
|
||||
timerRef.current = window.setTimeout(step, 16);
|
||||
timerRef.current = window.setTimeout(step, 12);
|
||||
};
|
||||
|
||||
step();
|
||||
|
||||
@@ -52,6 +52,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
|
||||
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
|
||||
import { DEFAULT_CANVAS_AGENT_SKILL_ID, ensureDefaultCanvasAgentSkill } from "@/services/skill-defaults";
|
||||
import { AGENT_SCENE_DEFS, AgentChatComposer, parseCloudAgentFormAnswer, AgentPlanBar, AgentQuestionBar, AgentSceneCapsules, type AgentSceneBucket, type CloudAgentChatMessage } from "./canvas-cloud-agent-chat-ui";
|
||||
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
|
||||
import { useSkillCuration, curationQuery } from "@/components/skills/skill-curation-browser";
|
||||
@@ -485,21 +486,52 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
.then(async (document) => {
|
||||
if (!active) return;
|
||||
const current = document.conversations.find((conversation) => conversation.id === document.activeId) || document.conversations[0];
|
||||
let initialSkillIds = current?.skillIds || [];
|
||||
if (!document.conversations.length) {
|
||||
try {
|
||||
const defaults = await ensureDefaultCanvasAgentSkill();
|
||||
if (!active) return;
|
||||
setSkills(defaults.skills);
|
||||
initialSkillIds = defaults.skills.some((skill) => skill.skillId === DEFAULT_CANVAS_AGENT_SKILL_ID && skill.isAdded)
|
||||
? [DEFAULT_CANVAS_AGENT_SKILL_ID]
|
||||
: [];
|
||||
} catch (cause) {
|
||||
if (active) setMessages((currentMessages) => appendAgentError(currentMessages, "default-skill-error", cause, "默认短剧技能未能安装,已保留空技能选择"));
|
||||
}
|
||||
}
|
||||
setConversations(document.conversations);
|
||||
if (current) {
|
||||
setActiveConversationId(current.id);
|
||||
setMessages(current.messages);
|
||||
setRun(current.run);
|
||||
setPermissionMode(current.permissionMode);
|
||||
setSelectedSkillIds(current.skillIds || []);
|
||||
setSelectedSkillIds(initialSkillIds);
|
||||
if (current.model) setModel(current.model);
|
||||
const pending = await loadCloudAgentPendingSubmission(canvasId, current.id);
|
||||
if (!active) return;
|
||||
pendingSubmission.current = pending;
|
||||
if (pending?.request) setPrompt(pending.displayText || pending.request.prompt);
|
||||
} else {
|
||||
setActiveConversationId(nanoid());
|
||||
const initialConversationId = nanoid();
|
||||
const now = new Date().toISOString();
|
||||
const initialConversation: CloudAgentConversation = {
|
||||
id: initialConversationId,
|
||||
title: "新对话",
|
||||
messages: [],
|
||||
run: null,
|
||||
model: selectedModel || undefined,
|
||||
permissionMode,
|
||||
skillIds: initialSkillIds,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
setActiveConversationId(initialConversationId);
|
||||
setConversations([initialConversation]);
|
||||
setSelectedSkillIds(initialSkillIds);
|
||||
pendingSubmission.current = null;
|
||||
void saveCloudAgentConversations(canvasId, initialConversationId, [initialConversation]).catch((cause) => {
|
||||
if (active) setMessages((currentMessages) => appendAgentError(currentMessages, "initial-conversation-save-error", cause, "新对话初始化保存失败,请重试后再发送"));
|
||||
});
|
||||
}
|
||||
setPendingHydrated(true);
|
||||
})
|
||||
@@ -516,7 +548,16 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
}, [canvasId, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyHydrated || (!messages.length && !run)) return;
|
||||
if (!historyHydrated) return;
|
||||
if (!messages.length && !run) {
|
||||
setConversations((current) => {
|
||||
const existing = current.find((conversation) => conversation.id === activeConversationId);
|
||||
if (!existing || existing.skillIds?.join("\0") === selectedSkillIds.join("\0")) return current;
|
||||
const now = new Date().toISOString();
|
||||
return current.map((conversation) => conversation.id === activeConversationId ? { ...conversation, skillIds: selectedSkillIds, updatedAt: now } : conversation);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
setConversations((current) => {
|
||||
const existing = current.find((conversation) => conversation.id === activeConversationId);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
|
||||
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
|
||||
import type { ModelReferenceLimits } from "@/lib/model-selection";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
|
||||
export type CharacterGenerationReference = {
|
||||
nodeId: string;
|
||||
@@ -444,18 +445,28 @@ export function buildNodeResponseMessages(context: NodeGenerationContext): AiTex
|
||||
];
|
||||
}
|
||||
|
||||
export async function hydrateNodeGenerationContext(context: NodeGenerationContext, projectId: string, domainProjectId?: string, mode?: CanvasGenerationMode, includeCharacterVoiceSamples = false, includeCharacterPrompt = true, referenceLimits?: ModelReferenceLimits) {
|
||||
export async function hydrateNodeGenerationContext(
|
||||
context: NodeGenerationContext,
|
||||
projectId: string,
|
||||
domainProjectId?: string,
|
||||
mode?: CanvasGenerationMode,
|
||||
includeCharacterVoiceSamples = false,
|
||||
includeCharacterPrompt = true,
|
||||
referenceLimits?: ModelReferenceLimits,
|
||||
materializeStoredMedia = true,
|
||||
) {
|
||||
const { imageToDataUrl } = await import("@/services/image-storage");
|
||||
let referenceImages = await Promise.all(
|
||||
context.referenceImages.map(async (image) => {
|
||||
if (image.source?.kind === "drawing") return resolveCanvasDrawingReference(projectId, image);
|
||||
if (image.source?.kind === "colorgrade") return resolveCanvasColorGradeReference(image);
|
||||
if (!materializeStoredMedia && resourceIdFromStorageKey(image.storageKey)) return image;
|
||||
return { ...image, dataUrl: await imageToDataUrl(image) };
|
||||
}),
|
||||
);
|
||||
if (!context.characterReferences.length) return { ...context, referenceImages };
|
||||
const { getCharacter } = await import("@/services/api/projects");
|
||||
const { getResource, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
|
||||
const { getResource, resourceFileUrl, resourceIdFromStorageKey: getResourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
|
||||
const details = await Promise.all(context.characterReferences.map((reference) => getCharacter(reference.assetId)));
|
||||
const remainingBudget = Math.max(0, (referenceLimits?.maxImages ?? 9) - referenceImages.length);
|
||||
const selectedEntries = details.flatMap((detail, index) => {
|
||||
@@ -484,7 +495,9 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
|
||||
dataUrl: "",
|
||||
storageKey: resourceStorageKey(entry.representation.resourceId),
|
||||
} satisfies ReferenceImage));
|
||||
const hydratedCharacterImages = await Promise.all(characterImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) })));
|
||||
const hydratedCharacterImages = materializeStoredMedia
|
||||
? await Promise.all(characterImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) })))
|
||||
: characterImages;
|
||||
const primaryByNodeId = new Map<string, ReferenceImage>();
|
||||
selectedEntries.forEach((entry, index) => {
|
||||
const image = hydratedCharacterImages[index];
|
||||
@@ -545,7 +558,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
|
||||
instructions: [language && `语言与口音:${language}`, voiceAge && `声音年龄感:${voiceAge}`, timbre && `音色气质:${timbre}`, deliveryInstructions].filter(Boolean).join(";"),
|
||||
}];
|
||||
});
|
||||
const usedAudioResourceIds = new Set(context.referenceAudios.map((audio) => resourceIdFromStorageKey(audio.storageKey)).filter(Boolean));
|
||||
const usedAudioResourceIds = new Set(context.referenceAudios.map((audio) => getResourceIdFromStorageKey(audio.storageKey)).filter(Boolean));
|
||||
const voiceSamples: ResolvedCharacterVoice[] = [];
|
||||
// 视频模型接收声音样本;独立配音任务仍通过 voiceKey 选音色,不能把两种协议混用。
|
||||
if (mode === "video" && includeCharacterVoiceSamples) {
|
||||
@@ -570,7 +583,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
|
||||
}));
|
||||
const referenceAudios = [...context.referenceAudios, ...characterVoiceAudios];
|
||||
const voiceBlocks = mode === "video" ? resolvedCharacterVoices.flatMap((voice) => {
|
||||
const sampleIndex = voice.sampleResourceId ? referenceAudios.findIndex((audio) => resourceIdFromStorageKey(audio.storageKey) === voice.sampleResourceId) : -1;
|
||||
const sampleIndex = voice.sampleResourceId ? referenceAudios.findIndex((audio) => getResourceIdFromStorageKey(audio.storageKey) === voice.sampleResourceId) : -1;
|
||||
if (sampleIndex < 0 && !includeCharacterPrompt) return [];
|
||||
return [[
|
||||
includeCharacterPrompt ? compileResolvedVoicePrompt(voice) : `【角色声音:${voice.characterName}】`,
|
||||
|
||||
@@ -8,18 +8,11 @@ import { type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { buildImageResolutionOptions, formatImageResolutionSize } from "@/lib/image-resolution-tiers";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { mergedImageCapabilityConfig } from "@/lib/model-selection";
|
||||
import { imagePriceSelector, normalizeImagePriceSelector } from "@/lib/image-pricing";
|
||||
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
|
||||
import { imageQualityLabel } from "@/lib/image-quality";
|
||||
|
||||
const qualityOptions = [
|
||||
{ value: "auto", label: "自动" },
|
||||
{ value: "high", label: "高" },
|
||||
{ value: "medium", label: "中" },
|
||||
{ value: "low", label: "低" },
|
||||
{ value: "1k", label: "1K" },
|
||||
{ value: "2k", label: "2K" },
|
||||
{ value: "4k", label: "4K" },
|
||||
];
|
||||
|
||||
export { imageQualityLabel } from "@/lib/image-quality";
|
||||
type AspectOption = { value: string; label: string; width: number; height: number; icon: string; size?: string };
|
||||
|
||||
const aspectOptions: AspectOption[] = [
|
||||
@@ -65,7 +58,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
const effectiveMaxCount = Math.min(maxCount, profile.maxOutputs);
|
||||
const count = Math.max(1, Math.min(effectiveMaxCount, Number(normalized.count)));
|
||||
const activeSize = normalized.size;
|
||||
const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value });
|
||||
const activeQualityOptions = profile.quality.values.map((value) => ({ value, label: imageQualityLabel(value) }));
|
||||
const priceTiers = imageModelPriceTiers(config);
|
||||
|
||||
return (
|
||||
@@ -142,10 +135,6 @@ export function ImageSettingsTheme({ theme, children }: { theme: CanvasTheme; ch
|
||||
);
|
||||
}
|
||||
|
||||
export function imageQualityLabel(value: string) {
|
||||
return ({ auto: "自动", high: "高", medium: "中", low: "低", "1k": "1K", "2k": "2K", "4k": "4K" } as Record<string, string>)[value.toLowerCase()] || value || "默认";
|
||||
}
|
||||
|
||||
function isGrokResolutionQuality(profile: ImageCapabilityConfig) {
|
||||
const values = profile.quality.values.map((item) => item.toLowerCase());
|
||||
return values.some((value) => ["1k", "2k", "4k"].includes(value));
|
||||
@@ -163,11 +152,12 @@ function imageModelPriceTiers(config: AiConfig) {
|
||||
}
|
||||
|
||||
function hasPriceTierForImageSelection(tiers: ReturnType<typeof imageModelPriceTiers>, quality: string, size: string) {
|
||||
if (!tiers.length) return true;
|
||||
return tiers.some((tier) => {
|
||||
const selector = tier.selector || {};
|
||||
return (!selector.quality || selector.quality === "*" || selector.quality === quality.toLowerCase()) && (!selector.size || selector.size === "*" || selector.size === size.toLowerCase());
|
||||
});
|
||||
if (!tiers.length) return true;
|
||||
const requested = imagePriceSelector(quality, size);
|
||||
return tiers.some((tier) => {
|
||||
const selector = normalizeImagePriceSelector(tier.selector || {});
|
||||
return Object.entries(selector).every(([key, value]) => value === "*" || requested[key] === value);
|
||||
});
|
||||
}
|
||||
|
||||
function OptionPill({ selected, disabled = false, theme, onClick, children }: { selected: boolean; disabled?: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
|
||||
|
||||
@@ -162,6 +162,36 @@
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 84px), 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.image-size-preset-option-wrap {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.image-size-preset-dimensions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-tiny);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.image-size-preset-dimensions label {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.image-size-preset-dimensions label > span {
|
||||
padding-inline: 2px;
|
||||
}
|
||||
.image-size-preset-dimensions .ant-input-number {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.image-size-preset-dimensions .ant-input-number-input {
|
||||
padding-inline: 5px;
|
||||
font-size: var(--fs-tiny);
|
||||
}
|
||||
.image-size-preset-option,
|
||||
.image-size-default-options button {
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useId, useState } from "react";
|
||||
import { Button, Input } from "antd";
|
||||
import { Button, Input, InputNumber } from "antd";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/base/switch";
|
||||
import "./image-size-picker.css";
|
||||
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio, imageResolutionUsesQuality, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
|
||||
import { validateImageSize } from "@/services/api/image-validation";
|
||||
import type { ImageResolutionTier } from "@/lib/image-resolution-tiers";
|
||||
|
||||
export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void }) {
|
||||
@@ -36,6 +37,25 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const editDimensions = (tier: ImageResolutionTier, ratio: string, field: "width" | "height", rawValue: number | null) => {
|
||||
if (disabled) return;
|
||||
const value = rawValue == null ? NaN : Math.round(rawValue);
|
||||
const selected = presets.find((preset) => preset.tier === tier && preset.ratio === ratio);
|
||||
if (!selected || !Number.isInteger(value) || value <= 0) {
|
||||
setErrors((current) => ({ ...current, [tier]: "请输入正整数像素" }));
|
||||
return;
|
||||
}
|
||||
const width = field === "width" ? value : selected.width;
|
||||
const height = field === "height" ? value : selected.height;
|
||||
try {
|
||||
validateImageSize(width, height);
|
||||
const next = presets.map((preset) => preset === selected ? { ...preset, width, height, size: `${width}x${height}` } : preset);
|
||||
onChange(imageSizeConfigWithPresets(profile, next));
|
||||
setErrors((current) => ({ ...current, [tier]: "" }));
|
||||
} catch (reason) {
|
||||
setErrors((current) => ({ ...current, [tier]: reason instanceof Error ? reason.message : "像素尺寸无效" }));
|
||||
}
|
||||
};
|
||||
const addRatio = (tier: ImageResolutionTier) => {
|
||||
const ratios = (drafts[tier] || "")
|
||||
.split(/[,,\n]/)
|
||||
@@ -68,24 +88,38 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
|
||||
const selected = items.find((item) => item.ratio === ratio);
|
||||
const dimensions = selected || imagePresetForRatio(tier, ratio);
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={Boolean(selected)}
|
||||
aria-label={`${tier.toUpperCase()} ${ratio}`}
|
||||
className="image-size-preset-option"
|
||||
title={`${ratio} · ${dimensions.width} × ${dimensions.height} px${selected ? " · 点击移除" : " · 点击添加"}`}
|
||||
onClick={() => update(tier, selected ? items.filter((item) => item.ratio !== ratio).map((item) => item.ratio) : [...items.map((item) => item.ratio), ratio])}
|
||||
>
|
||||
<span className="image-size-preset-option-label">
|
||||
<span>{ratio}</span>
|
||||
{selected ? <Check size={12} aria-hidden="true" /> : <Plus size={12} aria-hidden="true" />}
|
||||
</span>
|
||||
<small>
|
||||
{dimensions.width} × {dimensions.height}
|
||||
</small>
|
||||
</button>
|
||||
<div key={ratio} className="image-size-preset-option-wrap">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={Boolean(selected)}
|
||||
aria-label={`${tier.toUpperCase()} ${ratio}`}
|
||||
className="image-size-preset-option"
|
||||
title={`${ratio} · ${dimensions.width} × ${dimensions.height} px${selected ? " · 点击移除" : " · 点击添加"}`}
|
||||
onClick={() => update(tier, selected ? items.filter((item) => item.ratio !== ratio).map((item) => item.ratio) : [...items.map((item) => item.ratio), ratio])}
|
||||
>
|
||||
<span className="image-size-preset-option-label">
|
||||
<span>{ratio}</span>
|
||||
{selected ? <Check size={12} aria-hidden="true" /> : <Plus size={12} aria-hidden="true" />}
|
||||
</span>
|
||||
<small>
|
||||
{dimensions.width} × {dimensions.height}
|
||||
</small>
|
||||
</button>
|
||||
{selected ? (
|
||||
<div className="image-size-preset-dimensions" aria-label={`${tier.toUpperCase()} ${ratio} 精确像素`}>
|
||||
<label>
|
||||
<span>宽</span>
|
||||
<InputNumber min={1} precision={0} value={selected.width} disabled={disabled} aria-label={`${tier.toUpperCase()} ${ratio} 宽度像素`} onChange={(value) => editDimensions(tier, ratio, "width", value)} />
|
||||
</label>
|
||||
<span aria-hidden="true">×</span>
|
||||
<label>
|
||||
<span>高</span>
|
||||
<InputNumber min={1} precision={0} value={selected.height} disabled={disabled} aria-label={`${tier.toUpperCase()} ${ratio} 高度像素`} onChange={(value) => editDimensions(tier, ratio, "height", value)} />
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSP
|
||||
import { Check, ChevronDown, Coins } from "lucide-react";
|
||||
import { Popover } from "antd";
|
||||
|
||||
import { imageQualityLabel } from "@/lib/image-quality";
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
|
||||
import { formatPriceRange, modelQuoteDescription, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing";
|
||||
@@ -485,7 +486,8 @@ function tierSpecificationLabel(tier: NonNullable<NonNullable<AiConfig["channels
|
||||
const operation = selector.operation && selector.operation !== "*" ? operationLabels[selector.operation] || selector.operation : "";
|
||||
const details = [
|
||||
operation,
|
||||
selector.quality && selector.quality !== "*" ? selector.quality.toUpperCase() : "",
|
||||
selector.quality && selector.quality !== "*" ? imageQualityLabel(selector.quality) : "",
|
||||
selector.resolution && selector.resolution !== "*" ? imageQualityLabel(selector.resolution) : "",
|
||||
selector.size && selector.size !== "*" ? selector.size : "",
|
||||
tier.resolution !== "*" ? tierResolutionLabel(tier.resolution) : "",
|
||||
tier.videoSeconds ? tierDurationLabel(tier.videoSeconds) : "",
|
||||
|
||||
Reference in New Issue
Block a user