feat(assets): 文件夹主题 - 新增多主题皮肤并同步主线

This commit is contained in:
echoD886
2026-08-20 16:33:51 +08:00
93 changed files with 4081 additions and 1143 deletions

View 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;
}

View File

@@ -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 };

View 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;
}

View File

@@ -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;

View File

@@ -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";
}