feat: 完成短剧项目空间与结构化资产创作闭环 (#38)

* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP

* feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环

Refs #30

* fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置

* feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环

Refs #30

---------

Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
ddcat666
2026-07-27 10:45:11 +08:00
committed by GitHub
parent 431b9e3294
commit d2fbc413b3
208 changed files with 22655 additions and 4234 deletions

View File

@@ -9,6 +9,7 @@ import type { DirectorScene } from "@/types/director";
export type CanvasProject = {
id: string;
projectId?: string;
title: string;
createdAt: string;
updatedAt: string;
@@ -25,13 +26,13 @@ export type CanvasProject = {
type CanvasStore = {
hydrated: boolean;
projects: CanvasProject[];
createProject: (title?: string) => string;
createProject: (title?: string, projectId?: string) => string;
importProject: (project: Partial<CanvasProject>) => string;
openProject: (id: string) => CanvasProject | null;
renameProject: (id: string, title: string) => void;
deleteProjects: (ids: string[]) => void;
replaceProjects: (projects: CanvasProject[]) => void;
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "showImageInfo" | "viewport" | "directorScenes">>) => void;
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "projectId" | "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "showImageInfo" | "viewport" | "directorScenes">>) => void;
};
const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 };
@@ -82,11 +83,12 @@ export const useCanvasStore = create<CanvasStore>()(
(set, get) => ({
hydrated: false,
projects: [],
createProject: (title = "未命名画布") => {
createProject: (title = "未命名画布", projectId) => {
const now = new Date().toISOString();
const id = nanoid();
const project: CanvasProject = {
id,
projectId,
title,
createdAt: now,
updatedAt: now,
@@ -106,6 +108,7 @@ export const useCanvasStore = create<CanvasStore>()(
const now = new Date().toISOString();
const project: CanvasProject = {
id: nanoid(),
projectId: source.projectId,
title: source.title || "导入画布",
createdAt: source.createdAt || now,
updatedAt: now,

View File

@@ -6,12 +6,23 @@ import { localForageStorage } from "@/lib/localforage-storage";
import { cleanupUnusedImages, resolveImageUrl, uploadImage } from "@/services/image-storage";
import { cleanupUnusedMedia, resolveMediaUrl } from "@/services/file-storage";
export type AssetKind = "text" | "image" | "video" | "model";
export type AssetKind = "text" | "image" | "video" | "audio" | "model" | "entity";
export type AssetCategory = "character" | "environment" | "wardrobe" | "prop" | "weapon" | "style" | "other";
export type AssetStatus = "draft" | "review" | "confirmed" | "archived";
export type TextAsset = AssetBase<"text"> & { data: { content: string } };
export type ImageAsset = AssetBase<"image"> & { data: { dataUrl: string; storageKey?: string; width: number; height: number; bytes: number; mimeType: string } };
export type VideoAsset = AssetBase<"video"> & { data: { url: string; storageKey?: string; width: number; height: number; bytes: number; mimeType: string } };
export type VideoAsset = AssetBase<"video"> & { data: { url: string; storageKey?: string; width: number; height: number; durationMs?: number; bytes: number; mimeType: string } };
export type AudioAsset = AssetBase<"audio"> & { data: { url: string; storageKey?: string; durationMs?: number; bytes: number; mimeType: string } };
export type ModelAsset = AssetBase<"model"> & { data: { url: string; storageKey?: string; bytes: number; mimeType: string; fileName: string } };
export type Asset = TextAsset | ImageAsset | VideoAsset | ModelAsset;
export type EntityAsset = AssetBase<"entity"> & { data: { definition: Record<string, unknown> } };
export type Asset = TextAsset | ImageAsset | VideoAsset | AudioAsset | ModelAsset | EntityAsset;
export type NewAsset =
| Omit<TextAsset, "id" | "createdAt" | "updatedAt">
| Omit<ImageAsset, "id" | "createdAt" | "updatedAt">
| Omit<VideoAsset, "id" | "createdAt" | "updatedAt">
| Omit<AudioAsset, "id" | "createdAt" | "updatedAt">
| Omit<ModelAsset, "id" | "createdAt" | "updatedAt">
| Omit<EntityAsset, "id" | "createdAt" | "updatedAt">;
type AssetBase<T extends AssetKind> = {
id: string;
@@ -19,6 +30,9 @@ type AssetBase<T extends AssetKind> = {
title: string;
coverUrl: string;
tags: string[];
category?: AssetCategory;
status?: AssetStatus;
primaryVersionId?: string;
source?: string;
note?: string;
createdAt: string;
@@ -29,7 +43,7 @@ type AssetBase<T extends AssetKind> = {
type AssetStore = {
hydrated: boolean;
assets: Asset[];
addAsset: (asset: Omit<Asset, "id" | "createdAt" | "updatedAt">) => string;
addAsset: (asset: NewAsset) => string;
updateAsset: (id: string, patch: Partial<Omit<Asset, "id" | "createdAt">>) => void;
removeAsset: (id: string) => void;
replaceAssets: (assets: Asset[]) => void;
@@ -45,7 +59,7 @@ const assetStorage: PersistStorage<AssetStore> = {
const parsed = JSON.parse(value) as StorageValue<AssetStore>;
parsed.state.assets = await Promise.all(
parsed.state.assets.map(async (asset) => {
if (asset.kind === "video" && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } };
if ((asset.kind === "video" || asset.kind === "audio") && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } };
if (asset.kind === "model" && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } };
if (asset.kind !== "image") return asset;
if (asset.data.storageKey)

View File

@@ -23,8 +23,9 @@ export type ModelChannel = {
concurrencyLimit?: number;
modelCosts?: Array<{
model: string;
displayName?: string;
capability: ModelCapability;
billingMode: "fixed_request";
billingMode: "fixed_request" | "per_second";
unitPriceMicrocredits: number;
}>;
};
@@ -110,16 +111,10 @@ export const defaultConfig: AiConfig = {
type ConfigStore = {
config: AiConfig;
isConfigOpen: boolean;
shouldPromptContinue: boolean;
updateConfig: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
replaceConfig: (config: AiConfig) => void;
mergeSystemChannels: (channels: ModelChannel[]) => void;
isAiConfigReady: (config: AiConfig, model: string) => boolean;
configDialogSection?: "channels" | "models" | "preferences" | "storage";
openConfigDialog: (shouldPromptContinue?: boolean, section?: "channels" | "models" | "preferences" | "storage") => void;
setConfigDialogOpen: (isOpen: boolean) => void;
clearPromptContinue: () => void;
};
export type ConfigStoreSnapshot = {
@@ -171,6 +166,8 @@ export function filterModelsByCapability(models: string[], capability?: ModelCap
return models.filter((model) => {
const decoded = decodeChannelModel(model);
const channel = decoded ? channels?.find((item) => item.id === decoded.channelId) : undefined;
const configuredCapability = channel?.modelCosts?.find((item) => item.model === decoded?.model)?.capability;
if (configuredCapability) return configuredCapability === capability;
const channelCapability = capabilityForChannelInterface(channel?.interfaceType);
return channelCapability ? channelCapability === capability : modelMatchesCapability(model, capability);
});
@@ -178,7 +175,7 @@ export function filterModelsByCapability(models: string[], capability?: ModelCap
export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) {
if (!capability) return config.models;
return config[modelListKey(capability)];
return filterModelsByCapability(config.models, capability, config.channels);
}
export function configuredModelMatchesCapability(config: AiConfig, model: string, capability?: ModelCapability) {
@@ -187,10 +184,6 @@ export function configuredModelMatchesCapability(config: AiConfig, model: string
return capability ? selectableModelsByCapability(config, capability).includes(normalized) : true;
}
function modelListKey(capability: ModelCapability) {
return `${capability}Models` as "imageModels" | "videoModels" | "textModels" | "audioModels";
}
function isAiConfigReady(config: AiConfig, model: string) {
const channel = resolveModelChannel(config, model);
return Boolean(model.trim() && channel.baseUrl.trim() && channel.apiKey.trim());
@@ -200,9 +193,6 @@ export const useConfigStore = create<ConfigStore>()(
persist(
(set) => ({
config: defaultConfig,
isConfigOpen: false,
shouldPromptContinue: false,
configDialogSection: undefined,
updateConfig: (key, value) =>
set((state) => ({
config: {
@@ -226,9 +216,6 @@ export const useConfigStore = create<ConfigStore>()(
return normalizeConfigSnapshot({ config: { ...state.config, channels: [...systemChannels, ...userChannels] } });
}),
isAiConfigReady: (config, model) => isAiConfigReady(config, model),
openConfigDialog: (shouldPromptContinue = false, configDialogSection) => set({ isConfigOpen: true, shouldPromptContinue, configDialogSection }),
setConfigDialogOpen: (isConfigOpen) => set({ isConfigOpen }),
clearPromptContinue: () => set({ shouldPromptContinue: false }),
}),
{
name: CONFIG_STORE_KEY,
@@ -252,10 +239,10 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot) {
if (!hasPersistedChannels) config.channels = [];
const channels = normalizeChannels(config, !hasPersistedChannels);
const models = modelOptionsFromChannels(channels);
const imageModels = normalizeSelectableModelList(persistedConfig.imageModels, channels, models, "image");
const videoModels = normalizeSelectableModelList(persistedConfig.videoModels, channels, models, "video");
const textModels = normalizeSelectableModelList(persistedConfig.textModels, channels, models, "text");
const audioModels = normalizeSelectableModelList(persistedConfig.audioModels, channels, models, "audio");
const imageModels = filterModelsByCapability(models, "image", channels);
const videoModels = filterModelsByCapability(models, "video", channels);
const textModels = filterModelsByCapability(models, "text", channels);
const audioModels = filterModelsByCapability(models, "audio", channels);
const model = normalizeSelectedModel(config.model || config.imageModel || config.textModel, channels, models);
return {
config: {
@@ -287,12 +274,6 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot) {
};
}
function normalizeModelList(models: string[], channels: ModelChannel[]) {
const allModelOptions = channels.flatMap((channel) => channel.models.map((model) => encodeChannelModel(channel.id, model)));
return Array.from(new Set((models || []).map((model) => normalizeModelOptionValue(model, channels)).filter(Boolean)))
.filter((model) => allModelOptions.includes(model));
}
function normalizeSelectedModel(value: string, channels: ModelChannel[], options: string[]) {
const model = normalizeModelOptionValue(value, channels);
return model && options.includes(model) ? model : options[0] || "";
@@ -340,11 +321,18 @@ export function modelOptionName(value: string) {
return decodeChannelModel(value)?.model || value;
}
export function modelDisplayName(config: AiConfig, value: string) {
const model = modelOptionName(value);
const channel = resolveModelChannel(config, value);
return channel.modelCosts?.find((item) => item.model === model)?.displayName?.trim() || model;
}
export function modelOptionLabel(config: AiConfig, value: string) {
const decoded = decodeChannelModel(value);
if (!decoded) return value;
if (!decoded) return modelDisplayName(config, value);
const channel = config.channels.find((item) => item.id === decoded.channelId);
return channel ? `${decoded.model}(${channel.name})` : decoded.model;
const displayName = modelDisplayName(config, value);
return channel ? `${displayName}(${channel.name})` : displayName;
}
export function modelOptionsFromChannels(channels: ModelChannel[]) {
@@ -423,13 +411,6 @@ function normalizeChannels(config: AiConfig, ensureDefault = true) {
return channels.map((channel) => ({ ...channel, models: uniqueRawModels(channel.models) }));
}
function normalizeSelectableModelList(value: string[] | undefined, channels: ModelChannel[], models: string[], capability: ModelCapability) {
const suggested = filterModelsByCapability(models, capability, channels);
if (!Array.isArray(value)) return suggested;
// 字段缺失表示旧配置尚未选择过;显式空数组表示用户主动清空,不能重新补回推荐模型。
return normalizeModelList(value, channels);
}
function isEmptyDefaultChannel(channel: ModelChannel) {
if (channel.scope === "system") return false;
if (channel.id !== "default" || channel.name.trim() !== "默认渠道" || channel.apiKey.trim()) return false;