feat(assets): 文件夹主题 - 新增多主题皮肤并同步主线
This commit is contained in:
23
web/src/lib/canvas/canvas-folder-theme.ts
Normal file
23
web/src/lib/canvas/canvas-folder-theme.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { CanvasFolderTheme } from "@/types/canvas";
|
||||
|
||||
export const CANVAS_FOLDER_THEME_OPTIONS: ReadonlyArray<{
|
||||
key: CanvasFolderTheme;
|
||||
label: string;
|
||||
cover: string;
|
||||
}> = [
|
||||
{ key: "aurora", label: "赤蓝流光", cover: "/images/canvas/folder-default-cover.png" },
|
||||
{ key: "obsidian", label: "曜石银蓝", cover: "/images/canvas/folder-theme-obsidian.png" },
|
||||
{ key: "ember", label: "熔金赤焰", cover: "/images/canvas/folder-theme-ember.png" },
|
||||
{ key: "pearl", label: "珍珠薄雾", cover: "/images/canvas/folder-theme-pearl.png" },
|
||||
];
|
||||
|
||||
export function resolveCanvasFolderTheme(value?: string): CanvasFolderTheme {
|
||||
return CANVAS_FOLDER_THEME_OPTIONS.some((theme) => theme.key === value) ? value as CanvasFolderTheme : "aurora";
|
||||
}
|
||||
|
||||
export function resolveCanvasFolderThemeCover(theme?: string, customCover?: string) {
|
||||
const custom = customCover?.trim();
|
||||
if (custom) return custom;
|
||||
const resolved = resolveCanvasFolderTheme(theme);
|
||||
return CANVAS_FOLDER_THEME_OPTIONS.find((item) => item.key === resolved)!.cover;
|
||||
}
|
||||
@@ -52,14 +52,14 @@ export async function runBackendCanvasGenerationTask(
|
||||
},
|
||||
dependencies?: GenerationTaskDependencies,
|
||||
) {
|
||||
const normalizedReferenceImages = mode === "image" ? limitCanvasImageReferences(config, referenceImages) : referenceImages;
|
||||
if (mode === "image") assertCanvasImageReferenceLimit(config, referenceImages);
|
||||
return runBackendGenerationTask(
|
||||
{
|
||||
projectId,
|
||||
mode,
|
||||
prompt,
|
||||
config,
|
||||
referenceImages: normalizedReferenceImages,
|
||||
referenceImages,
|
||||
referenceVideos,
|
||||
referenceAudios,
|
||||
mask,
|
||||
@@ -74,12 +74,15 @@ export async function runBackendCanvasGenerationTask(
|
||||
);
|
||||
}
|
||||
|
||||
// Canvas references can include a scene frame plus multiple character turnarounds. Keep
|
||||
// the earliest connected scene image when a provider accepts fewer images than the graph.
|
||||
export function limitCanvasImageReferences(config: AiConfig, referenceImages: ReferenceImage[]) {
|
||||
export function canvasImageReferenceLimitError(config: AiConfig, referenceImages: ReferenceImage[]) {
|
||||
const maxImages = modelCapabilityConfigFor(config, config.model).image?.references.maxImages;
|
||||
if (maxImages === undefined || maxImages < 1 || referenceImages.length <= maxImages) return referenceImages;
|
||||
return referenceImages.slice(0, maxImages);
|
||||
if (maxImages === undefined || referenceImages.length <= maxImages) return "";
|
||||
return `当前图片模型最多支持 ${maxImages} 张参考图,当前已连接 ${referenceImages.length} 张。请移除多余连线后重试`;
|
||||
}
|
||||
|
||||
export function assertCanvasImageReferenceLimit(config: AiConfig, referenceImages: ReferenceImage[]) {
|
||||
const error = canvasImageReferenceLimitError(config, referenceImages);
|
||||
if (error) throw new Error(error);
|
||||
}
|
||||
|
||||
export { backendProviderConfig };
|
||||
|
||||
237
web/src/lib/channel-model-catalog.ts
Normal file
237
web/src/lib/channel-model-catalog.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolCapability, protocolForModelCatalog, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
export type ChannelModelCatalogOption = { value: string; label?: string };
|
||||
|
||||
export type ChannelModelCatalogItem = {
|
||||
id: string;
|
||||
displayName?: string;
|
||||
modelType?: "text" | "image" | "video" | "audio";
|
||||
supportedEndpointTypes?: string[];
|
||||
defaultParameters?: {
|
||||
aspectRatio?: string;
|
||||
durationSeconds?: string;
|
||||
resolution?: string;
|
||||
};
|
||||
options?: {
|
||||
aspectRatio?: ChannelModelCatalogOption[];
|
||||
durationSeconds?: ChannelModelCatalogOption[];
|
||||
resolution?: ChannelModelCatalogOption[];
|
||||
};
|
||||
supportsImages?: boolean;
|
||||
minImages?: number;
|
||||
maxImages?: number;
|
||||
};
|
||||
|
||||
type ChannelModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
|
||||
|
||||
export function sanitizeChannelModelCatalogItem(value: unknown): ChannelModelCatalogItem | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const id = stringValue(record.id);
|
||||
if (!id) return null;
|
||||
const modelType = stringValue(record.modelType).toLowerCase();
|
||||
const defaultParameters = objectValue(record.defaultParameters);
|
||||
const options = objectValue(record.options);
|
||||
const normalizedDefaults = defaultParameters
|
||||
? {
|
||||
...(stringValue(defaultParameters.aspectRatio) ? { aspectRatio: stringValue(defaultParameters.aspectRatio) } : {}),
|
||||
...(stringValue(defaultParameters.durationSeconds) ? { durationSeconds: stringValue(defaultParameters.durationSeconds) } : {}),
|
||||
...(stringValue(defaultParameters.resolution) ? { resolution: stringValue(defaultParameters.resolution) } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const normalizedOptions = options
|
||||
? {
|
||||
...(catalogOptions(options.aspectRatio).length ? { aspectRatio: catalogOptions(options.aspectRatio) } : {}),
|
||||
...(catalogOptions(options.durationSeconds).length ? { durationSeconds: catalogOptions(options.durationSeconds) } : {}),
|
||||
...(catalogOptions(options.resolution).length ? { resolution: catalogOptions(options.resolution) } : {}),
|
||||
}
|
||||
: undefined;
|
||||
return compactCatalogItem({
|
||||
id,
|
||||
displayName: stringValue(record.displayName),
|
||||
modelType: ["text", "image", "video", "audio"].includes(modelType) ? (modelType as ChannelModelCatalogItem["modelType"]) : undefined,
|
||||
supportedEndpointTypes: stringArray(record.supportedEndpointTypes),
|
||||
defaultParameters: normalizedDefaults,
|
||||
options: normalizedOptions,
|
||||
supportsImages: typeof record.supportsImages === "boolean" ? record.supportsImages : undefined,
|
||||
minImages: nonNegativeInteger(record.minImages),
|
||||
maxImages: nonNegativeInteger(record.maxImages),
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeFetchedChannelModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] {
|
||||
const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost]));
|
||||
const next: ChannelModelCost[] = [];
|
||||
for (const item of catalog) {
|
||||
const existing = existingByModel.get(item.id);
|
||||
const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes);
|
||||
const inferredCapability = modelProtocolCapability(inferredProtocol) || item.modelType;
|
||||
if (existing) {
|
||||
const protocol = inferredProtocol || existing.protocol;
|
||||
const capability = inferredCapability || existing.capability;
|
||||
const capabilityChanged = capability !== existing.capability;
|
||||
const patchCapabilityConfig = hasCatalogCapabilityConfig(item) && (capability === "image" || capability === "video");
|
||||
const capabilityConfig = patchCapabilityConfig
|
||||
? catalogCapabilityConfig(item, protocol || channel.interfaceType, capability, capabilityChanged ? undefined : existing.capabilityConfig, false)
|
||||
: capabilityChanged
|
||||
? undefined
|
||||
: existing.capabilityConfig;
|
||||
next.push({
|
||||
...existing,
|
||||
...(item.displayName ? { displayName: item.displayName } : {}),
|
||||
capability,
|
||||
...(inferredProtocol ? { protocol: inferredProtocol } : {}),
|
||||
...(patchCapabilityConfig || capabilityChanged ? { capabilityConfig } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const capability = inferredCapability || modelProtocolCapability(channel.interfaceType);
|
||||
const protocol = inferredProtocol || protocolTemplateForNewCatalogModel(capability, channel.interfaceType);
|
||||
if (!protocol || !capability) continue;
|
||||
const capabilityConfig = capability === "image" || capability === "video" ? catalogCapabilityConfig(item, protocol, capability, undefined, true) : undefined;
|
||||
next.push({
|
||||
model: item.id,
|
||||
...(item.displayName ? { displayName: item.displayName } : {}),
|
||||
capability,
|
||||
protocol,
|
||||
billingMode: "fixed_request",
|
||||
unitPriceMicrocredits: 0,
|
||||
...(capabilityConfig ? { capabilityConfig } : {}),
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function protocolTemplateForNewCatalogModel(capability: ChannelModelCost["capability"] | undefined, channelProtocol: ModelProtocol | undefined): ModelProtocol | undefined {
|
||||
if (!capability) return channelProtocol;
|
||||
if (modelProtocolCapability(channelProtocol) === capability) return channelProtocol;
|
||||
const templates: Record<ChannelModelCost["capability"], ModelProtocol> = { text: "chat-completion", image: "openai-image", video: "newapi", audio: "openai-audio" };
|
||||
return templates[capability];
|
||||
}
|
||||
|
||||
function hasCatalogCapabilityConfig(item: ChannelModelCatalogItem) {
|
||||
return Boolean(item.defaultParameters || item.options || item.supportsImages !== undefined || item.minImages !== undefined || item.maxImages !== undefined);
|
||||
}
|
||||
|
||||
function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelProtocol | undefined, capability: "image" | "video", existing: ModelCapabilityConfig | undefined, isNew: boolean): ModelCapabilityConfig {
|
||||
const fallback = defaultModelCapabilityConfig(protocol, item.id);
|
||||
const existingProfile = capability === "image" ? existing?.image : existing?.video;
|
||||
const config = structuredClone(existingProfile ? existing! : fallback);
|
||||
if (capability === "image") {
|
||||
if (item.supportsImages === false) config.image!.references.maxImages = 0;
|
||||
else if (item.maxImages !== undefined) config.image!.references.maxImages = item.maxImages;
|
||||
return config;
|
||||
}
|
||||
|
||||
const video = config.video!;
|
||||
if (isNew) {
|
||||
video.resolutions = [];
|
||||
video.defaultResolution = "";
|
||||
}
|
||||
const durations = uniqueNumbers(optionValues(item.options?.durationSeconds));
|
||||
const defaultDuration = positiveNumber(item.defaultParameters?.durationSeconds);
|
||||
if (durations.length) {
|
||||
video.duration = { selection: "enum", values: durations, default: durations.includes(defaultDuration) ? defaultDuration : durations[0]! };
|
||||
} else if (defaultDuration > 0) {
|
||||
video.duration = { selection: "enum", values: [defaultDuration], default: defaultDuration };
|
||||
}
|
||||
const ratios = optionValues(item.options?.aspectRatio);
|
||||
const defaultRatio = item.defaultParameters?.aspectRatio?.trim() || "";
|
||||
if (ratios.length) {
|
||||
video.ratios = ratios;
|
||||
video.defaultRatio = ratios.includes(defaultRatio) ? defaultRatio : ratios[0]!;
|
||||
} else if (defaultRatio) {
|
||||
video.ratios = [defaultRatio];
|
||||
video.defaultRatio = defaultRatio;
|
||||
}
|
||||
|
||||
// A catalog-backed video may only emit resolution_name when the provider
|
||||
// explicitly advertises compatible values. An empty list means omit it.
|
||||
const resolutions = optionValues(item.options?.resolution);
|
||||
const defaultResolution = item.defaultParameters?.resolution?.trim() || "";
|
||||
if (resolutions.length || defaultResolution) {
|
||||
video.resolutions = resolutions.length ? resolutions : [defaultResolution];
|
||||
video.defaultResolution = video.resolutions.includes(defaultResolution) ? defaultResolution : video.resolutions[0] || "";
|
||||
}
|
||||
|
||||
if (item.supportsImages === false || item.maxImages === 0) {
|
||||
video.references.minImages = 0;
|
||||
video.references.maxImages = 0;
|
||||
video.operations = video.operations.filter((operation) => operation !== "image_to_video");
|
||||
if (!video.operations.length) video.operations = ["text_to_video"];
|
||||
video.defaultOperation = video.operations.includes(video.defaultOperation) ? video.defaultOperation : video.operations[0]!;
|
||||
} else {
|
||||
if (item.maxImages !== undefined) video.references.maxImages = item.maxImages;
|
||||
if (item.minImages !== undefined) video.references.minImages = item.minImages;
|
||||
if (video.references.minImages > 0) {
|
||||
video.references.maxImages = Math.max(video.references.minImages, video.references.maxImages);
|
||||
video.operations = video.operations.filter((operation) => operation !== "text_to_video");
|
||||
if (!video.operations.includes("image_to_video")) video.operations.unshift("image_to_video");
|
||||
video.defaultOperation = "image_to_video";
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function compactCatalogItem(item: ChannelModelCatalogItem): ChannelModelCatalogItem {
|
||||
const defaultParameters = item.defaultParameters && Object.values(item.defaultParameters).some(Boolean) ? item.defaultParameters : undefined;
|
||||
const options = item.options && Object.values(item.options).some((values) => values?.length) ? item.options : undefined;
|
||||
return {
|
||||
id: item.id,
|
||||
...(item.displayName ? { displayName: item.displayName } : {}),
|
||||
...(item.modelType ? { modelType: item.modelType } : {}),
|
||||
...(item.supportedEndpointTypes?.length ? { supportedEndpointTypes: item.supportedEndpointTypes } : {}),
|
||||
...(defaultParameters ? { defaultParameters } : {}),
|
||||
...(options ? { options } : {}),
|
||||
...(item.supportsImages !== undefined ? { supportsImages: item.supportsImages } : {}),
|
||||
...(item.minImages !== undefined ? { minImages: item.minImages } : {}),
|
||||
...(item.maxImages !== undefined ? { maxImages: item.maxImages } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function objectValue(value: unknown) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function stringArray(value: unknown) {
|
||||
return Array.isArray(value) ? Array.from(new Set(value.map(stringValue).filter(Boolean))) : [];
|
||||
}
|
||||
|
||||
function catalogOptions(value: unknown): ChannelModelCatalogOption[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const options: ChannelModelCatalogOption[] = [];
|
||||
for (const raw of value) {
|
||||
const record = objectValue(raw);
|
||||
const optionValue = stringValue(record?.value);
|
||||
if (!optionValue || seen.has(optionValue)) continue;
|
||||
seen.add(optionValue);
|
||||
const label = stringValue(record?.label);
|
||||
options.push({ value: optionValue, ...(label ? { label } : {}) });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function optionValues(options: ChannelModelCatalogOption[] | undefined) {
|
||||
return Array.from(new Set((options || []).map((option) => option.value.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function positiveNumber(value: unknown) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function uniqueNumbers(values: string[]) {
|
||||
return Array.from(new Set(values.map(positiveNumber).filter((value) => value > 0)));
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export type ImageCapabilityConfig = {
|
||||
export type VideoCapabilityConfig = {
|
||||
references: {
|
||||
promptMaxChars: number;
|
||||
minImages: number;
|
||||
maxImages: number;
|
||||
maxImageBytes: number;
|
||||
maxVideos: number;
|
||||
@@ -133,6 +134,7 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
const video: VideoCapabilityConfig = {
|
||||
references: {
|
||||
promptMaxChars: 1000,
|
||||
minImages: 0,
|
||||
maxImages: 9,
|
||||
maxImageBytes: 30 * 1024 * 1024,
|
||||
maxVideos: 0,
|
||||
@@ -145,14 +147,17 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
duration: { selection: "range", min: 1, max: 15, step: 1, default: 6 },
|
||||
ratios: ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"],
|
||||
defaultRatio: "16:9",
|
||||
resolutions: ["480p", "720p", "1080p", "2160p"],
|
||||
resolutions: ["480p", "720p", "1080p", "1440p", "2160p"],
|
||||
defaultResolution: "720p",
|
||||
generateAudio: { supported: false, default: false },
|
||||
watermark: { supported: false, default: false },
|
||||
operations: ["text_to_video", "image_to_video"],
|
||||
defaultOperation: "text_to_video",
|
||||
};
|
||||
if (protocol === "volcengine-jimeng-video") video.duration = { selection: "enum", values: [5, 10], default: 5 };
|
||||
if (protocol === "volcengine-jimeng-video") {
|
||||
video.duration = { selection: "enum", values: [5, 10], default: 5 };
|
||||
video.resolutions = ["720p"];
|
||||
}
|
||||
if (protocol === "gemini-veo") {
|
||||
video.duration = { selection: "enum", values: [4, 6, 8], default: 6 };
|
||||
video.resolutions = ["720p", "1080p"];
|
||||
@@ -166,6 +171,7 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
video.references.maxAudioDurationSeconds = 15;
|
||||
video.generateAudio = { supported: true, default: true };
|
||||
}
|
||||
if (protocol === "volcengine-ark-video" || protocol === "newapi-channel-1") video.resolutions = ["480p", "720p", "1080p"];
|
||||
if (protocol === "volcengine-ark-video") video.watermark = { supported: true, default: false };
|
||||
if (protocol === "novita-video") {
|
||||
video.references.maxImages = 1;
|
||||
@@ -185,9 +191,11 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
|
||||
const channel = config.channels.find((item) => item.id === channelId) || config.channels.find((item) => item.models.includes(modelName));
|
||||
const cost = channel?.modelCosts?.find((item) => item.model === modelName);
|
||||
const fallback = defaultModelCapabilityConfig(cost?.protocol, modelName);
|
||||
return cost?.capabilityConfig
|
||||
? { ...fallback, ...cost.capabilityConfig, image: cost.capabilityConfig.image || fallback.image, video: cost.capabilityConfig.video || fallback.video }
|
||||
: fallback;
|
||||
if (!cost?.capabilityConfig) return fallback;
|
||||
const video = cost.capabilityConfig.video
|
||||
? { ...fallback.video!, ...cost.capabilityConfig.video, references: { ...fallback.video!.references, ...cost.capabilityConfig.video.references } }
|
||||
: fallback.video;
|
||||
return { ...fallback, ...cost.capabilityConfig, image: cost.capabilityConfig.image || fallback.image, video };
|
||||
}
|
||||
|
||||
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
|
||||
@@ -224,6 +232,24 @@ export function normalizeVideoValue(profile: VideoCapabilityConfig, value: { sec
|
||||
return { seconds: String(duration), ratio, resolution };
|
||||
}
|
||||
|
||||
export function videoResolutionRequest(profile: VideoCapabilityConfig, value: string | undefined) {
|
||||
const requested = String(value || "").trim().toLowerCase();
|
||||
if (!requested || requested === "auto" || requested === "default" || requested === "medium" || requested === "high") return undefined;
|
||||
const candidates = [requested];
|
||||
if (/^\d+$/.test(requested)) candidates.push(`${requested}p`);
|
||||
if (requested === "low") candidates.push("480p");
|
||||
if (requested === "2k") candidates.push("1440p");
|
||||
if (requested === "1440" || requested === "1440p") candidates.push("2k");
|
||||
if (requested === "4k") candidates.push("2160p");
|
||||
if (requested === "2160" || requested === "2160p") candidates.push("4k");
|
||||
const supported = new Map(profile.resolutions.map((resolution) => [resolution.trim().toLowerCase(), resolution.trim()]));
|
||||
for (const candidate of candidates) {
|
||||
const match = supported.get(candidate);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeRangeDuration(profile: VideoCapabilityConfig, value: number) {
|
||||
const min = profile.duration.min || 1;
|
||||
const max = profile.duration.max || min;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const VIDEO_DURATION_OPTIONS = [6, 9, 10, 15] as const;
|
||||
export const VIDEO_RESOLUTION_OPTIONS = [480, 720, 1080, 2160] as const;
|
||||
export const VIDEO_RESOLUTION_OPTIONS = [480, 720, 1080, 1440, 2160] as const;
|
||||
export const VIDEO_RESOLUTION_CAPABILITY_OPTIONS = VIDEO_RESOLUTION_OPTIONS.map((value) => `${value}p`);
|
||||
export const VIDEO_DURATION_MIN = 1;
|
||||
|
||||
export function normalizeVideoDuration(value: string | number | undefined) {
|
||||
@@ -11,11 +12,8 @@ export function normalizeVideoResolution(value: string | number | undefined) {
|
||||
const token = String(value || "").trim().toLowerCase();
|
||||
if (token === "low") return "480";
|
||||
if (token === "auto" || token === "medium" || token === "high") return "720";
|
||||
if (token === "2k") return "1440";
|
||||
if (token === "4k") return "2160";
|
||||
const resolution = Number(token.replace(/p$/i, "")) || 720;
|
||||
return String(nearestOption(resolution, VIDEO_RESOLUTION_OPTIONS));
|
||||
}
|
||||
|
||||
function nearestOption(value: number, options: readonly number[]) {
|
||||
return options.reduce((nearest, option) => Math.abs(option - value) < Math.abs(nearest - value) ? option : nearest, options[0]);
|
||||
const resolution = Number(token.replace(/p$/i, ""));
|
||||
return Number.isFinite(resolution) && resolution > 0 ? String(Math.floor(resolution)) : "720";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user