feat(canvas): 画布能力 - 完善 LibTV 导入与资源预览
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
type AssetMediaPreviewProps = {
|
||||
@@ -32,7 +33,8 @@ export function AssetMediaPreview({ asset, alt, className = "", fallback = null
|
||||
);
|
||||
}
|
||||
|
||||
const storageKey = asset.kind === "image" ? asset.data.storageKey : undefined;
|
||||
const imageUrl = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
|
||||
if (!imageUrl) return fallback;
|
||||
return <img src={imageUrl} alt={alt} loading="lazy" decoding="async" className={className} />;
|
||||
if (!imageUrl && !storageKey) return fallback;
|
||||
return <CachedResourceImage storageKey={storageKey} src={imageUrl} alt={alt} loading="lazy" decoding="async" className={className} fallback={fallback} />;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, FileText, FolderOpen, Image as ImageIcon, LoaderCircle, Music2,
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
@@ -13,6 +14,7 @@ export type AssetLibraryPickerItem = {
|
||||
kindLabel: string;
|
||||
asset?: Asset;
|
||||
imageUrl?: string;
|
||||
imageStorageKey?: string;
|
||||
imageFit?: "cover" | "contain";
|
||||
description?: string;
|
||||
searchText?: string;
|
||||
@@ -210,7 +212,7 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem
|
||||
return (
|
||||
<button type="button" className={cn("asset-picker-card", selected && "is-selected", disabled && "is-disabled")} onClick={onToggle} disabled={disabled} aria-pressed={selected} title={item.disabledReason || item.title}>
|
||||
<div className="asset-picker-card-media">
|
||||
{item.imageUrl ? <img src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} />}
|
||||
{item.imageUrl || item.imageStorageKey ? <CachedResourceImage storageKey={item.imageStorageKey} src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} />}
|
||||
<span className="asset-picker-card-check"><Check /></span>
|
||||
<span className="asset-picker-card-kind">{item.kindLabel}</span>
|
||||
{item.disabledReason ? <span className="asset-picker-card-lock">{item.disabledReason}</span> : null}
|
||||
|
||||
75
web/src/components/cached-resource-image.tsx
Normal file
75
web/src/components/cached-resource-image.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
|
||||
type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
|
||||
storageKey?: string;
|
||||
src?: string;
|
||||
fallback?: ReactNode;
|
||||
eager?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 资源图片优先读取按用户隔离的本地 Blob 缓存,避免刷新后再次从对象存储下载。
|
||||
* 普通外链、data URL 和本地 Blob URL 不经过资源缓存,保持原有行为。
|
||||
*/
|
||||
export function CachedResourceImage({ storageKey, src = "", fallback = null, eager = false, onError, ...props }: CachedResourceImageProps) {
|
||||
const remoteResource = Boolean(resourceIdFromStorageKey(storageKey));
|
||||
const targetRef = useRef<HTMLSpanElement>(null);
|
||||
const [nearViewport, setNearViewport] = useState(eager || !remoteResource);
|
||||
const [cachedSrc, setCachedSrc] = useState(remoteResource ? "" : src);
|
||||
const [cacheFailed, setCacheFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!remoteResource || eager) {
|
||||
setNearViewport(true);
|
||||
return;
|
||||
}
|
||||
const image = targetRef.current;
|
||||
if (!image || typeof IntersectionObserver === "undefined") {
|
||||
setNearViewport(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setNearViewport(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
}, { rootMargin: "240px" });
|
||||
observer.observe(image);
|
||||
return () => observer.disconnect();
|
||||
}, [eager, remoteResource]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCacheFailed(false);
|
||||
if (!remoteResource || !storageKey) {
|
||||
setCachedSrc(src);
|
||||
return () => { cancelled = true; };
|
||||
}
|
||||
if (!nearViewport) {
|
||||
setCachedSrc("");
|
||||
return () => { cancelled = true; };
|
||||
}
|
||||
|
||||
setCachedSrc("");
|
||||
const resolve = cacheResourceObjectUrl(storageKey);
|
||||
void resolve.then((url) => {
|
||||
if (!cancelled) setCachedSrc(url || src);
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setCacheFailed(true);
|
||||
setCachedSrc(src);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [nearViewport, remoteResource, src, storageKey]);
|
||||
|
||||
if (!remoteResource) return <img {...props} src={cachedSrc} onError={onError} />;
|
||||
return (
|
||||
<span ref={targetRef} className="cached-resource-image-shell">
|
||||
{cachedSrc && !(cacheFailed && !src) ? <img {...props} src={cachedSrc} onError={onError} /> : fallback}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type
|
||||
import { Crosshair, FolderOpen, ImageIcon, Images, PanelLeftClose, Plus, Search, X } from "lucide-react";
|
||||
|
||||
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
@@ -229,7 +230,7 @@ function AssetTrayRow({ title, imageUrl, storageKey, icon, active = false, dragg
|
||||
onDragStartCapture={onDragStart}
|
||||
>
|
||||
<span className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--dock-item-radius)] border" style={{ background: theme.node.fill, borderColor: theme.toolbar.border }}>
|
||||
{imageUrl ? <img src={imageUrl} alt="" width={36} height={36} className="size-full object-cover" draggable={false} /> : <ImageIcon className="size-3.5 opacity-55" />}
|
||||
{imageUrl || storageKey ? <CachedResourceImage storageKey={storageKey} src={imageUrl} alt="" width={36} height={36} className="size-full object-cover" draggable={false} fallback={<ImageIcon className="size-3.5 opacity-55" />} /> : <ImageIcon className="size-3.5 opacity-55" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[var(--fs-tiny)] font-semibold">{title}</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerat
|
||||
import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel, isGenerationTaskSubmissionUncertain } from "@/lib/generation-task-display";
|
||||
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
|
||||
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { buildLibTVImagePreviewUrl } from "@/lib/canvas/libtv-import";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
@@ -417,10 +418,11 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null);
|
||||
const nearViewport = useNearViewport(imageContainerRef);
|
||||
const { url, loading } = useNodeResourceUrl(node, nearViewport);
|
||||
const importedFromLibTV = node.metadata?.importSource?.provider === "libtv";
|
||||
return (
|
||||
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>
|
||||
<div ref={imageContainerRef} className="h-full w-full overflow-hidden rounded-[var(--node-radius)]">
|
||||
{url ? <img src={url} alt={node.title} loading="lazy" decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
|
||||
{url ? <img src={url} alt={node.title} loading={importedFromLibTV ? "eager" : "lazy"} decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
|
||||
</div>
|
||||
</BatchFrame>
|
||||
);
|
||||
@@ -432,7 +434,9 @@ function DeferredMediaLoad({ icon, label, disabled, onClick }: { icon: ReactNode
|
||||
|
||||
function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
|
||||
const storageKey = node.metadata?.storageKey || "";
|
||||
const fallback = node.metadata?.content || "";
|
||||
const content = node.metadata?.content || "";
|
||||
const fallback = node.metadata?.previewContent
|
||||
|| (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content);
|
||||
const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey));
|
||||
const [url, setUrl] = useState(isRemoteResource ? "" : fallback);
|
||||
const [loading, setLoading] = useState(isRemoteResource && eager);
|
||||
|
||||
@@ -36,6 +36,7 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
|
||||
kindLabel: character ? "角色卡" : media?.kind === "video" ? "视频" : media?.kind === "audio" ? "音频" : media?.kind === "text" ? "文本" : "图片",
|
||||
asset: media,
|
||||
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : undefined,
|
||||
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
|
||||
imageFit: character ? "contain" : "cover",
|
||||
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : undefined,
|
||||
searchText: media?.tags.join(" "),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { resolveBackendApiUrl } from "@/stores/use-config-store";
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) {
|
||||
@@ -109,16 +110,16 @@ function ProjectPreview({ project }: { project: CanvasProject }) {
|
||||
.flatMap((node) => {
|
||||
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return [];
|
||||
const url = getNodeMediaUrl(node);
|
||||
return isPreviewUrl(url) ? [{ node, url }] : [];
|
||||
return isPreviewUrl(url) ? [{ node, url, storageKey: node.metadata?.storageKey }] : [];
|
||||
});
|
||||
const media = mediaNodes.find(({ node }) => node.type === CanvasNodeType.Image) || mediaNodes[0];
|
||||
if (media) {
|
||||
const { node, url } = media;
|
||||
const { node, url, storageKey } = media;
|
||||
return (
|
||||
<div className="canvas-project-media size-full">
|
||||
{node.type === CanvasNodeType.Video
|
||||
? <div className="canvas-project-video size-full"><Video className="size-8" aria-label={node.title || "项目视频"} /></div>
|
||||
: <img src={url} alt={node.title || "项目图片"} loading="lazy" decoding="async" className="size-full min-h-0 object-cover" />}
|
||||
: <CachedResourceImage storageKey={storageKey} src={url} alt={node.title || "项目图片"} loading="lazy" decoding="async" className="size-full min-h-0 object-cover" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { createCanvasShare, deleteCanvasShare, getCanvasShare, type CanvasShareStatus } from "@/services/api/canvas-share";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
|
||||
export function CanvasShareModal({ projectId, open, onClose, beforeCreate }: { projectId: string; open: boolean; onClose: () => void; beforeCreate: () => Promise<void> }) {
|
||||
export function CanvasShareModal({ projectId, open, onClose, beforeCreate }: { projectId: string; open: boolean; onClose: () => void; beforeCreate: () => Promise<boolean | void> }) {
|
||||
const { message, modal } = App.useApp();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [share, setShare] = useState<CanvasShareStatus>({ enabled: false });
|
||||
@@ -40,7 +40,8 @@ export function CanvasShareModal({ projectId, open, onClose, beforeCreate }: { p
|
||||
const create = async (rotate = false) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await beforeCreate();
|
||||
const saved = await beforeCreate();
|
||||
if (saved === false) return;
|
||||
const result = await createCanvasShare(projectId, { expiresDays, rotate });
|
||||
setShare(result.share);
|
||||
const url = result.share.token ? `${window.location.origin}/share/canvas/${result.share.token}` : "";
|
||||
|
||||
@@ -35,12 +35,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const options = useMemo(() => {
|
||||
const filtered = selectableModelsByCapability(config, capability);
|
||||
const current = value?.trim();
|
||||
const currentIncluded = current ? filtered.includes(current) : true;
|
||||
return Array.from(new Set([...filtered, ...(!currentIncluded && current ? [current] : [])].filter((model): model is string => Boolean(model))));
|
||||
}, [capability, config, value]);
|
||||
const options = useMemo(() => Array.from(new Set(selectableModelsByCapability(config, capability).filter(Boolean))), [capability, config]);
|
||||
const optionGroups = useMemo(() => {
|
||||
const channelGroups = config.channels
|
||||
.map((channel) => ({
|
||||
@@ -53,13 +48,15 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.models.length);
|
||||
const groupedModels = new Set(channelGroups.flatMap((group) => group.models.flatMap((modelGroup) => modelGroup.models)));
|
||||
const ungroupedModels = options.filter((model) => !groupedModels.has(model));
|
||||
return ungroupedModels.length ? [...channelGroups, { key: "ungrouped", label: "其他模型", scope: "未指定渠道", models: groupModelsByDisplayName(config, ungroupedModels) }] : channelGroups;
|
||||
// options 已由当前有效渠道重建;任何无法解析渠道的旧值都直接丢弃,
|
||||
// 不再显示“其他模型 / 未指定渠道”这种不可用入口。
|
||||
return channelGroups;
|
||||
}, [config, options]);
|
||||
const current = value || "";
|
||||
const resolvedCurrent = resolveCompatibleModel(config, current, requirements) || current;
|
||||
const currentPrice = modelMenuPrice(config, resolvedCurrent);
|
||||
const storedCurrent = value?.trim() || "";
|
||||
const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, requirements) || storedCurrent;
|
||||
// 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。
|
||||
const current = options.includes(resolvedCurrent) ? resolvedCurrent : "";
|
||||
const currentPrice = modelMenuPrice(config, current);
|
||||
const creationVariant = variant === "creation";
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
36
web/src/lib/canvas/libtv-import.ts
Normal file
36
web/src/lib/canvas/libtv-import.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const LIBTV_PROJECT_UUID_PATTERN = /^[a-f0-9]{32}$/i;
|
||||
const LIBTV_RESOURCE_HOST = "libtv-res.liblib.art";
|
||||
const LIBTV_PREVIEW_WIDTH = 960;
|
||||
|
||||
export function parseLibTVProjectUUID(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
let candidate = trimmed;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
candidate = parsed.searchParams.get("projectId") || parsed.searchParams.get("uuid") || parsed.pathname.split("/").filter(Boolean).at(-1) || "";
|
||||
} catch {
|
||||
candidate = trimmed;
|
||||
}
|
||||
return LIBTV_PROJECT_UUID_PATTERN.test(candidate) ? candidate.toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function formatLibTVBatchTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "未知";
|
||||
const pad = (part: number) => String(part).padStart(2, "0");
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function buildLibTVImagePreviewUrl(rawUrl: string) {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
if (url.protocol !== "https:" || url.hostname !== LIBTV_RESOURCE_HOST) return rawUrl;
|
||||
if (!url.searchParams.has("x-oss-process")) {
|
||||
url.searchParams.set("x-oss-process", `image/resize,w_${LIBTV_PREVIEW_WIDTH}`);
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
HardDrive,
|
||||
Home,
|
||||
Infinity as InfinityIcon,
|
||||
KeyRound,
|
||||
Layers3,
|
||||
Mail,
|
||||
MessageSquareText,
|
||||
@@ -72,6 +73,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" /> },
|
||||
{ path: "/admin/settings/storage", label: "存储服务", description: "对象存储与资源存储", icon: <HardDrive className="size-4" /> },
|
||||
{ path: "/admin/settings/response-interception", label: "模型响应拦截", description: "替换用户可见的上游异常", icon: <ShieldAlert className="size-4" /> },
|
||||
{ path: "/admin/settings/third-party", label: "第三方参数配置", description: "集中维护第三方平台凭证", icon: <KeyRound className="size-4" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
143
web/src/pages/admin/settings/libtv-settings-page.tsx
Normal file
143
web/src/pages/admin/settings/libtv-settings-page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { App, Button, Input, Popconfirm, Switch } from "antd";
|
||||
import { KeyRound, Save, Trash2, Wifi } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getAdminLibTVSetting, testAdminLibTV, updateAdminLibTVSetting, type AdminLibTVSetting } from "@/services/api/libtv";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
|
||||
|
||||
export default function LibTVSettingsPage() {
|
||||
const { message } = App.useApp();
|
||||
const [setting, setSetting] = useState<AdminLibTVSetting>({ enabled: false, hasToken: false });
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [token, setToken] = useState("");
|
||||
const [testUuid, setTestUuid] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getAdminLibTVSetting()
|
||||
.then(({ setting: next }) => {
|
||||
if (cancelled) return;
|
||||
setSetting(next);
|
||||
setEnabled(next.enabled);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) message.error(error instanceof Error ? error.message : "读取 LibTV 配置失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [message]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const { setting: next } = await updateAdminLibTVSetting({ enabled, token: token.trim() || undefined });
|
||||
setSetting(next);
|
||||
setToken("");
|
||||
setEnabled(next.enabled);
|
||||
message.success("LibTV 配置已更新");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存 LibTV 配置失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearToken = async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
const { setting: next } = await updateAdminLibTVSetting({ enabled: false, clearToken: true });
|
||||
setSetting(next);
|
||||
setToken("");
|
||||
setEnabled(false);
|
||||
message.success("LibTV Token 已清空,导入功能已停用");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "清空 LibTV Token 失败");
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
if (!testUuid.trim()) {
|
||||
message.error("请填写用于测试的 LibTV 画布 UUID");
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
try {
|
||||
await testAdminLibTV(testUuid.trim());
|
||||
message.success("LibTV 连接测试成功");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "LibTV 连接测试失败");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPageFrame title="第三方参数配置" description="集中维护外部平台的服务端凭证和连接状态,后续平台可在此继续扩展。">
|
||||
<div className="pt-4">
|
||||
<SettingsSectionCard
|
||||
icon={<KeyRound className="size-4" />}
|
||||
title="LibTV 配置"
|
||||
description="Token 仅保存在服务端加密配置中,不会回传到浏览器。"
|
||||
status={<AdminStatusBadge label={setting.hasToken ? "已配置" : "未配置"} tone={setting.hasToken ? "success" : "warning"} />}
|
||||
footer={
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-xs text-foreground/50">{setting.hasToken ? "已保存 Token;输入新值可替换,留空保存会保留现有 Token。" : "请先配置 Token,再启用导入功能。"}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{setting.hasToken ? (
|
||||
<Popconfirm title="确认清空 LibTV Token?" description="清空后将同时停用 LibTV 画布导入。" okText="清空" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={clearToken}>
|
||||
<Button danger icon={<Trash2 className="size-4" />} loading={clearing} disabled={loading || saving}>
|
||||
清空 Token
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={loading || clearing} onClick={() => void save()}>
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5 px-4 py-4">
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">LibTV Token</label>
|
||||
<Input.Password value={token} onChange={(event) => setToken(event.target.value)} placeholder={setting.hasToken ? "已配置(输入新 Token 可替换)" : "输入 LibTV Token"} disabled={loading || saving || clearing} autoComplete="new-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">启用导入</label>
|
||||
<div className="flex h-8 items-center gap-3">
|
||||
<Switch checked={enabled} onChange={setEnabled} disabled={loading || saving || clearing} />
|
||||
<span className="text-sm text-foreground/60">{enabled ? "已启用" : "已停用"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Wifi className="size-4" />
|
||||
测试连接
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Input className="min-w-64 flex-1" value={testUuid} onChange={(event) => setTestUuid(event.target.value)} placeholder="输入可访问的 LibTV 画布 UUID" />
|
||||
<Button loading={testing} onClick={() => void test()}>
|
||||
测试连接
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-foreground/50">用于验证当前 Token 是否可读取指定 LibTV 画布,不会向当前画布写入节点。</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Bot, Check, ChevronDown, Clapperboard, Coins, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { Bot, Check, ChevronDown, Clapperboard, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
|
||||
import { Button, Dropdown, Modal, Tooltip } from "antd";
|
||||
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
@@ -28,6 +28,7 @@ type CanvasTopBarProps = {
|
||||
onCreateProject: () => void;
|
||||
onDeleteProject: () => void;
|
||||
onImportImage: () => void;
|
||||
onImportLibTV: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onShare: () => void;
|
||||
@@ -58,6 +59,7 @@ export function CanvasTopBar({
|
||||
onCreateProject,
|
||||
onDeleteProject,
|
||||
onImportImage,
|
||||
onImportLibTV,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onShare,
|
||||
@@ -191,6 +193,9 @@ export function CanvasTopBar({
|
||||
|
||||
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5">
|
||||
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
|
||||
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CopyPlus className="size-4" />} onClick={onImportLibTV} aria-label="一键导入 LibTV 画布" title="一键导入 LibTV 画布">
|
||||
<span className="hidden xl:inline">导入 LibTV</span>
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
|
||||
176
web/src/pages/canvas/components/libtv-import-dialog.tsx
Normal file
176
web/src/pages/canvas/components/libtv-import-dialog.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { App, Button, Input, Modal, Tag } from "antd";
|
||||
import { CircleAlert, ExternalLink, Import, LoaderCircle } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { buildLibTVImagePreviewUrl, formatLibTVBatchTime, parseLibTVProjectUUID } from "@/lib/canvas/libtv-import";
|
||||
import { importLibTVCanvas, type LibTVImportResult } from "@/services/api/libtv";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
projectId: string;
|
||||
viewport: ViewportTransform;
|
||||
viewportSize: { width: number; height: number };
|
||||
onClose: () => void;
|
||||
onApply: (nodes: CanvasNodeData[], connections: CanvasConnection[]) => Promise<void>;
|
||||
};
|
||||
|
||||
function buildCanvasNodes(result: LibTVImportResult, viewport: ViewportTransform, viewportSize: { width: number; height: number }) {
|
||||
const minX = Math.min(...result.nodes.map((node) => node.x));
|
||||
const minY = Math.min(...result.nodes.map((node) => node.y));
|
||||
const maxX = Math.max(...result.nodes.map((node) => node.x + node.width));
|
||||
const maxY = Math.max(...result.nodes.map((node) => node.y + node.height));
|
||||
const offsetX = (viewportSize.width / 2 - viewport.x) / viewport.k - (minX + maxX) / 2;
|
||||
const offsetY = (viewportSize.height / 2 - viewport.y) / viewport.k - (minY + maxY) / 2;
|
||||
return result.nodes.map<CanvasNodeData>((node) => ({
|
||||
id: node.id,
|
||||
type: node.type === "video" ? CanvasNodeType.Video : CanvasNodeType.Image,
|
||||
title: node.title,
|
||||
position: { x: node.x + offsetX, y: node.y + offsetY },
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
metadata: {
|
||||
content: node.content,
|
||||
previewContent: node.type === "image" ? buildLibTVImagePreviewUrl(node.content) : undefined,
|
||||
prompt: node.prompt,
|
||||
model: node.model,
|
||||
status: node.status || "idle",
|
||||
errorDetails: node.errorDetails,
|
||||
naturalWidth: node.naturalWidth,
|
||||
naturalHeight: node.naturalHeight,
|
||||
durationMs: node.durationMs,
|
||||
mimeType: node.mimeType,
|
||||
importSource: node.metadata,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function LibTVImportDialog({ open, projectId, viewport, viewportSize, onClose, onApply }: Props) {
|
||||
const { message } = App.useApp();
|
||||
const [value, setValue] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<LibTVImportResult | null>(null);
|
||||
const uuid = useMemo(() => parseLibTVProjectUUID(value), [value]);
|
||||
|
||||
const reset = () => {
|
||||
setValue("");
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (loading) return;
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const changeValue = (nextValue: string) => {
|
||||
setValue(nextValue);
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!uuid) {
|
||||
message.error("请填写 LibTV 画布 UUID 或链接");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
setResult(await importLibTVCanvas(projectId, uuid));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取 LibTV 画布失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const apply = async () => {
|
||||
if (!result) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await onApply(buildCanvasNodes(result, viewport, viewportSize), result.connections);
|
||||
reset();
|
||||
onClose();
|
||||
message.success(`已导入 ${result.importedNodeCount} 个节点和 ${result.importedConnectionCount} 条连接`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存导入结果失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="workspace-modal"
|
||||
open={open}
|
||||
onCancel={close}
|
||||
title="导入 LibTV 画布"
|
||||
width={620}
|
||||
footer={
|
||||
result ? (
|
||||
[
|
||||
<Button key="close" onClick={close}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button key="apply" type="primary" icon={<Import className="size-4" />} loading={loading} onClick={() => void apply()}>
|
||||
确认导入
|
||||
</Button>,
|
||||
]
|
||||
) : (
|
||||
<Button type="primary" loading={loading} onClick={() => void load()}>
|
||||
读取画布
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">LibTV 画布 UUID 或链接</label>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(event) => changeValue(event.target.value)}
|
||||
placeholder="粘贴 32 位 UUID、画布链接或分享链接"
|
||||
disabled={loading}
|
||||
suffix={uuid && value !== uuid ? <ExternalLink className="size-4 text-foreground/35" /> : null}
|
||||
/>
|
||||
</div>
|
||||
{loading && !result ? (
|
||||
<div className="flex items-center gap-2 text-sm text-foreground/55">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
正在读取 LibTV 画布…
|
||||
</div>
|
||||
) : null}
|
||||
{result ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl p-4" style={{ background: "var(--library-surface)" }}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">{result.projectName || "LibTV 画布"}</div>
|
||||
<div className="mt-1 text-sm text-foreground/60">可导入 {result.importedNodeCount} 个节点 · {result.importedConnectionCount} 条连线</div>
|
||||
</div>
|
||||
<Tag color="blue">批次:{formatLibTVBatchTime(result.batchCreatedAt)}</Tag>
|
||||
</div>
|
||||
<div className="mt-3 text-xs leading-5 text-foreground/50">节点会保留相对位置,并整体放到当前可视区域中心。</div>
|
||||
</div>
|
||||
{result.skippedNodes.length || result.skippedConnections.length || result.multiResultNodeCount || result.staleNodeCount || result.reusedFailedNodeCount || result.placeholderNodeCount || result.convertedSpecialCount ? (
|
||||
<div className="flex items-start gap-2 rounded-lg px-3 py-2.5 text-xs leading-5 text-foreground/60" style={{ background: "var(--surface-hover)" }}>
|
||||
<CircleAlert className="mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
{result.skippedNodes.length ? <div>{result.skippedNodes.length} 个暂不支持的节点未导入,相关连线已自动忽略。</div> : null}
|
||||
{!result.skippedNodes.length && result.skippedConnections.length ? <div>{result.skippedConnections.length} 条无效连线已自动忽略。</div> : null}
|
||||
{result.reusedFailedNodeCount ? <div>{result.reusedFailedNodeCount} 个最近任务失败但仍有历史结果的节点已保留。</div> : null}
|
||||
{result.placeholderNodeCount ? <div>{result.placeholderNodeCount} 个尚未生成结果的节点已作为占位节点保留。</div> : null}
|
||||
{result.convertedSpecialCount ? <div>{result.convertedSpecialCount} 个特殊节点已转换为图片参考节点。</div> : null}
|
||||
{result.multiResultNodeCount ? <div>{result.multiResultNodeCount} 个多结果节点已使用首个结果。</div> : null}
|
||||
{result.staleNodeCount ? <div>{result.staleNodeCount} 个过期标记节点仍保留现有结果。</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tag>等待确认导入</Tag>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,7 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard
|
||||
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
|
||||
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
|
||||
import { CanvasTopBar } from "./canvas-project-top-bar";
|
||||
import { LibTVImportDialog } from "./components/libtv-import-dialog";
|
||||
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
|
||||
import { CanvasProjectContextMenu } from "./canvas-project-context-menu";
|
||||
import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
|
||||
@@ -210,6 +211,7 @@ function InfiniteCanvasPage() {
|
||||
const [scriptScrollTopById, setScriptScrollTopById] = useState<Record<string, number>>({});
|
||||
const [directorNodeId, setDirectorNodeId] = useState<string | null>(null);
|
||||
const [versionCompareRootId, setVersionCompareRootId] = useState<string | null>(null);
|
||||
const [libTVImportOpen, setLibTVImportOpen] = useState(false);
|
||||
const codexAutoConnect = shouldAutoConnectCanvasRuntime(searchParams);
|
||||
const codexCompactAgent = codexAutoConnect && readLocalRuntimeBootstrapState().legacyDeepLinkRejected;
|
||||
const [titleEditing, setTitleEditing] = useState(false);
|
||||
@@ -331,6 +333,28 @@ function InfiniteCanvasPage() {
|
||||
cleanupAssetImages,
|
||||
cleanupCanvasFiles,
|
||||
});
|
||||
|
||||
const applyLibTVImport = useCallback(
|
||||
async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => {
|
||||
const previousNodes = nodesRef.current;
|
||||
const previousConnections = connectionsRef.current;
|
||||
const nextNodes = [...nodesRef.current, ...importedNodes];
|
||||
const nextConnections = [...connectionsRef.current, ...importedConnections];
|
||||
nodesRef.current = nextNodes;
|
||||
connectionsRef.current = nextConnections;
|
||||
setNodes(nextNodes);
|
||||
setConnections(nextConnections);
|
||||
const saved = await saveCanvasProject();
|
||||
if (!saved) {
|
||||
nodesRef.current = previousNodes;
|
||||
connectionsRef.current = previousConnections;
|
||||
setNodes(previousNodes);
|
||||
setConnections(previousConnections);
|
||||
throw new Error("画布保存失败,已撤销本次 LibTV 导入");
|
||||
}
|
||||
},
|
||||
[saveCanvasProject, setConnections, setNodes],
|
||||
);
|
||||
const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : "";
|
||||
const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) });
|
||||
const refetchLinkedProject = linkedProjectQuery.refetch;
|
||||
@@ -1639,6 +1663,7 @@ function InfiniteCanvasPage() {
|
||||
onCreateProject={createAndOpenProject}
|
||||
onDeleteProject={deleteCurrentProject}
|
||||
onImportImage={() => handleUploadRequest()}
|
||||
onImportLibTV={() => setLibTVImportOpen(true)}
|
||||
onUndo={undoCanvas}
|
||||
onRedo={redoCanvas}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
@@ -1686,6 +1711,7 @@ function InfiniteCanvasPage() {
|
||||
) : null}
|
||||
|
||||
<CanvasShareModal projectId={projectId} open={shareModalOpen} onClose={() => setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
|
||||
<LibTVImportDialog open={libTVImportOpen} projectId={projectId} viewport={viewport} viewportSize={size} onClose={() => setLibTVImportOpen(false)} onApply={applyLibTVImport} />
|
||||
|
||||
<CanvasStylePickerModal open={stylePickerOpen} value={activeStylePresetId} applying={styleApplying} onClose={() => setStylePickerOpen(false)} onSelect={selectCanvasStyle} />
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ export function useCanvasProjectLifecycle({
|
||||
renameProject(projectId, title);
|
||||
}, [projectId, renameProject]);
|
||||
|
||||
const saveCanvasProject = useCallback(async () => {
|
||||
const saveCanvasProject = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
updateProject(projectId, {
|
||||
nodes: nodesRef.current,
|
||||
@@ -202,7 +202,7 @@ export function useCanvasProjectLifecycle({
|
||||
await flushCanvasStorePersistence();
|
||||
} catch {
|
||||
message.error("画布保存失败,请稍后重试");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await saveRemoteUserDataNow();
|
||||
@@ -211,6 +211,7 @@ export function useCanvasProjectLifecycle({
|
||||
const detail = error instanceof Error ? error.message : "未知错误";
|
||||
message.warning(`本地画布布局已保存,云端同步失败:${detail}`);
|
||||
}
|
||||
return true;
|
||||
}, [activeChatId, backgroundMode, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
|
||||
|
||||
const clearCanvasFiles = useCallback(() => {
|
||||
|
||||
@@ -109,20 +109,23 @@ export function useCanvasRenderModel({
|
||||
});
|
||||
return { left, top, width: Math.max(2, right - left), height: Math.max(2, bottom - top) };
|
||||
}, [nodes]);
|
||||
const visibleNodes = useMemo(() => {
|
||||
const renderBounds = useMemo(() => {
|
||||
const padding = (reduceMediaEffects ? Math.max(240, Math.max(viewportSize.width, viewportSize.height) * 0.4) : Math.max(800, Math.max(viewportSize.width, viewportSize.height) * 1.5)) / viewport.k;
|
||||
const viewLeft = -viewport.x / viewport.k - padding;
|
||||
const viewTop = -viewport.y / viewport.k - padding;
|
||||
const viewRight = viewLeft + viewportSize.width / viewport.k + padding * 2;
|
||||
const viewBottom = viewTop + viewportSize.height / viewport.k + padding * 2;
|
||||
return { left: viewLeft, top: viewTop, right: viewRight, bottom: viewBottom };
|
||||
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
|
||||
const visibleNodes = useMemo(() => {
|
||||
const frames: CanvasNodeData[] = [];
|
||||
const regular: CanvasNodeData[] = [];
|
||||
nodes.forEach((node) => {
|
||||
if (renderHiddenNodeIds.has(node.id) || node.position.x + node.width <= viewLeft || node.position.x >= viewRight || node.position.y + node.height <= viewTop || node.position.y >= viewBottom) return;
|
||||
if (renderHiddenNodeIds.has(node.id) || node.position.x + node.width <= renderBounds.left || node.position.x >= renderBounds.right || node.position.y + node.height <= renderBounds.top || node.position.y >= renderBounds.bottom) return;
|
||||
(isFrameNode(node) ? frames : regular).push(node);
|
||||
});
|
||||
return [...frames, ...regular];
|
||||
}, [nodes, reduceMediaEffects, renderHiddenNodeIds, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
|
||||
}, [nodes, renderBounds, renderHiddenNodeIds]);
|
||||
|
||||
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]);
|
||||
const canvasImageNodes = useMemo(() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)), [collapsedBatchChildIds, nodeById, nodes]);
|
||||
@@ -228,8 +231,13 @@ export function useCanvasRenderModel({
|
||||
if (displayFrom.id === displayTo.id) return [];
|
||||
const from = dragPreview?.nodeIds.has(displayFrom.id) ? { ...displayFrom, position: { x: displayFrom.position.x + dragPreview.x, y: displayFrom.position.y + dragPreview.y } } : displayFrom;
|
||||
const to = dragPreview?.nodeIds.has(displayTo.id) ? { ...displayTo, position: { x: displayTo.position.x + dragPreview.x, y: displayTo.position.y + dragPreview.y } } : displayTo;
|
||||
const connectionLeft = Math.min(from.position.x, to.position.x);
|
||||
const connectionTop = Math.min(from.position.y, to.position.y);
|
||||
const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
|
||||
const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
|
||||
if (connectionRight <= renderBounds.left || connectionLeft >= renderBounds.right || connectionBottom <= renderBounds.top || connectionTop >= renderBounds.bottom) return [];
|
||||
return [{ connection, from, to }];
|
||||
}), [collapsedBatchChildIds, connections, dragPreview, nodeById]);
|
||||
}), [collapsedBatchChildIds, connections, dragPreview, nodeById, renderBounds]);
|
||||
|
||||
const configInputsById = useMemo(() => {
|
||||
const map = new Map<string, NodeGenerationInput[]>();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Box, Check, ChevronDown, Download, FileText, Image as ImageIcon, Link2,
|
||||
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
|
||||
@@ -296,7 +297,7 @@ function ProjectAssetPreviewModal({ asset, personalAsset, onClose, onDownload, o
|
||||
<Modal className="workspace-modal workspace-modal-wide library-modal project-asset-preview-modal" title={asset?.category === "character" ? "角色卡预览" : "资产预览"} open={Boolean(asset)} onCancel={onClose} footer={<div className="flex justify-end gap-2"><Button onClick={onClose}>关闭</Button>{asset?.category === "character" ? <Button onClick={onReplaceImage}>替换图片</Button> : null}{canDownload ? <Button type="primary" icon={<Download className="size-3.5" />} onClick={onDownload}>下载</Button> : null}</div>}>
|
||||
{asset ? <div className="project-asset-preview-layout">
|
||||
<div className={`project-asset-preview-stage ${previewClass}`}>
|
||||
{asset.category === "character" ? characterCover ? <img src={resourceFileUrl(characterCover.resourceId)} alt={asset.title} className="project-asset-preview-media" /> : <div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div> : personalAsset?.kind === "video" ? <video src={personalAsset.data.url} controls className="project-asset-preview-media" /> : personalAsset?.kind === "audio" ? <audio src={personalAsset.data.url} controls className="project-asset-preview-audio" /> : personalAsset?.kind === "image" ? <img src={personalAsset.data.dataUrl || personalAsset.coverUrl} alt={asset.title} className="project-asset-preview-media" /> : personalAsset?.kind === "text" ? <p className="project-asset-preview-text">{personalAsset.data.content}</p> : <div className="grid min-h-48 place-items-center text-foreground/35"><MediaIcon kind={asset.mediaType} /></div>}
|
||||
{asset.category === "character" ? characterCover ? <CachedResourceImage storageKey={`resource:${characterCover.resourceId}`} src={resourceFileUrl(characterCover.resourceId)} alt={asset.title} className="project-asset-preview-media" fallback={<div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div>} /> : <div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div> : personalAsset?.kind === "video" ? <video src={personalAsset.data.url} controls className="project-asset-preview-media" /> : personalAsset?.kind === "audio" ? <audio src={personalAsset.data.url} controls className="project-asset-preview-audio" /> : personalAsset?.kind === "image" ? <AssetMediaPreview asset={personalAsset} alt={asset.title} className="project-asset-preview-media" /> : personalAsset?.kind === "text" ? <p className="project-asset-preview-text">{personalAsset.data.content}</p> : <div className="grid min-h-48 place-items-center text-foreground/35"><MediaIcon kind={asset.mediaType} /></div>}
|
||||
</div>
|
||||
<aside className="project-asset-preview-details">
|
||||
<div className="project-asset-preview-eyebrow">{asset.category === "character" ? "角色卡" : mediaLabel(asset.mediaType)}</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button, Popconfirm } from "antd";
|
||||
import { Image as ImageIcon, Pencil, Sparkles, Trash2, UserRound, Volume2 } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { resourceFileUrl } from "@/services/api/resources";
|
||||
import type { ProjectAsset } from "@/services/api/projects";
|
||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||
@@ -30,7 +31,7 @@ export function ProjectCharacterCard({ asset, generating, removing, onOpen, onEd
|
||||
<AssetLibraryCard className="project-character-card">
|
||||
<AssetLibraryCardMedia className="relative aspect-[3/2] overflow-hidden bg-foreground/[.045]">
|
||||
<button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看角色卡:${asset.title}`}>
|
||||
{cover ? <img src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
|
||||
{cover ? <CachedResourceImage storageKey={`resource:${cover.resourceId}`} src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" fallback={<div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>} /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
|
||||
</button>
|
||||
<div className="absolute inset-x-2 top-2 flex items-center justify-between gap-2">
|
||||
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[var(--fs-micro)] font-medium text-white">角色卡 · v{character?.version || 1}</span>
|
||||
|
||||
@@ -24,6 +24,7 @@ const RuntimePolicySettingsPage = lazy(() => import("@/pages/admin/settings/runt
|
||||
const DrawingEngineSettingsPage = lazy(() => import("@/pages/admin/settings/drawing-engine-settings-page"));
|
||||
const StorageSettingsPage = lazy(() => import("@/pages/admin/settings/storage-settings-page"));
|
||||
const ResponseInterceptionSettingsPage = lazy(() => import("@/pages/admin/settings/response-interception-settings-page"));
|
||||
const ThirdPartySettingsPage = lazy(() => import("@/pages/admin/settings/libtv-settings-page"));
|
||||
const StoryboardPromptsPage = lazy(() => import("@/pages/admin/storyboard-prompts/storyboard-prompts-page"));
|
||||
const UsersPage = lazy(() => import("@/pages/admin/users/users-page"));
|
||||
const AssetsPage = lazy(loadAssetsPage);
|
||||
@@ -149,6 +150,8 @@ export const router = createBrowserRouter([
|
||||
{ path: "settings/email", element: deferred(<EmailSettingsPage />) },
|
||||
{ path: "settings/storage", element: deferred(<StorageSettingsPage />) },
|
||||
{ path: "settings/response-interception", element: deferred(<ResponseInterceptionSettingsPage />) },
|
||||
{ path: "settings/third-party", element: deferred(<ThirdPartySettingsPage />) },
|
||||
{ path: "settings/libtv", element: <Navigate to="/admin/settings/third-party" replace /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
75
web/src/services/api/libtv.ts
Normal file
75
web/src/services/api/libtv.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { CanvasConnection } from "@/types/canvas";
|
||||
import { apiClient, request } from "@/services/api/request";
|
||||
|
||||
export type AdminLibTVSetting = {
|
||||
enabled: boolean;
|
||||
hasToken: boolean;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type LibTVImportIssue = { id?: string; name?: string; reason: string };
|
||||
export type LibTVImportWarning = { id?: string; message: string };
|
||||
|
||||
export type LibTVImportNode = {
|
||||
id: string;
|
||||
type: "image" | "video";
|
||||
title: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
content: string;
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
durationMs?: number;
|
||||
mimeType?: string;
|
||||
status?: "idle" | "success" | "error";
|
||||
errorDetails?: string;
|
||||
metadata: {
|
||||
provider: "libtv";
|
||||
projectUuid: string;
|
||||
nodeKey: string;
|
||||
batchId: string;
|
||||
sourceType?: string;
|
||||
styleAssetUuid?: string;
|
||||
styleVersionUuid?: string;
|
||||
styleName?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type LibTVImportResult = {
|
||||
batchId: string;
|
||||
batchCreatedAt: string;
|
||||
projectUuid: string;
|
||||
projectName: string;
|
||||
nodes: LibTVImportNode[];
|
||||
connections: CanvasConnection[];
|
||||
importedNodeCount: number;
|
||||
importedConnectionCount: number;
|
||||
skippedNodes: LibTVImportIssue[];
|
||||
skippedConnections: LibTVImportIssue[];
|
||||
warnings: LibTVImportWarning[];
|
||||
multiResultNodeCount: number;
|
||||
staleNodeCount: number;
|
||||
reusedFailedNodeCount: number;
|
||||
placeholderNodeCount: number;
|
||||
convertedSpecialCount: number;
|
||||
};
|
||||
|
||||
export function getAdminLibTVSetting() {
|
||||
return request<{ setting: AdminLibTVSetting }>(apiClient.get("/admin/settings/libtv"));
|
||||
}
|
||||
|
||||
export function updateAdminLibTVSetting(input: { enabled: boolean; token?: string; clearToken?: boolean }) {
|
||||
return request<{ setting: AdminLibTVSetting }>(apiClient.patch("/admin/settings/libtv", input));
|
||||
}
|
||||
|
||||
export function testAdminLibTV(uuid: string) {
|
||||
return request<{ ok: boolean }>(apiClient.post("/admin/settings/libtv/test", { uuid }));
|
||||
}
|
||||
|
||||
export function importLibTVCanvas(projectId: string, uuid: string) {
|
||||
return request<LibTVImportResult>(apiClient.post(`/canvas-projects/${encodeURIComponent(projectId)}/import/libtv`, { uuid }));
|
||||
}
|
||||
@@ -96,28 +96,20 @@ export type ModelCapability = "image" | "video" | "text" | "audio";
|
||||
const CHANNEL_MODEL_SEPARATOR = "::";
|
||||
const OPENAI_BASE_URL = "https://api.openai.com";
|
||||
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com";
|
||||
const LEGACY_DEFAULT_MODEL_NAMES = new Set(["gpt-image-2", "grok-imagine-video", "gpt-5.5", "gpt-4o-mini-tts"]);
|
||||
|
||||
export const defaultConfig: AiConfig = {
|
||||
channelMode: "local",
|
||||
baseUrl: OPENAI_BASE_URL,
|
||||
apiKey: "",
|
||||
apiFormat: "openai",
|
||||
channels: [
|
||||
{
|
||||
id: "default",
|
||||
name: "默认渠道",
|
||||
baseUrl: OPENAI_BASE_URL,
|
||||
allowLocalChannel: false,
|
||||
apiKey: "",
|
||||
apiFormat: "openai",
|
||||
models: ["gpt-image-2", "grok-imagine-video", "gpt-5.5", "gpt-4o-mini-tts"],
|
||||
},
|
||||
],
|
||||
model: "default::gpt-image-2",
|
||||
imageModel: "default::gpt-image-2",
|
||||
videoModel: "default::grok-imagine-video",
|
||||
textModel: "default::gpt-5.5",
|
||||
audioModel: "default::gpt-4o-mini-tts",
|
||||
// 创作端模型目录只能来自后台公开逻辑模型和用户自定义渠道,不能内置供应商模型。
|
||||
channels: [],
|
||||
model: "",
|
||||
imageModel: "",
|
||||
videoModel: "",
|
||||
textModel: "",
|
||||
audioModel: "",
|
||||
audioVoice: "alloy",
|
||||
audioFormat: "mp3",
|
||||
audioSpeed: "1",
|
||||
@@ -127,11 +119,11 @@ export const defaultConfig: AiConfig = {
|
||||
videoGenerateAudio: "true",
|
||||
videoWatermark: "false",
|
||||
systemPrompt: "",
|
||||
models: ["default::gpt-image-2", "default::grok-imagine-video", "default::gpt-5.5", "default::gpt-4o-mini-tts"],
|
||||
imageModels: ["default::gpt-image-2"],
|
||||
videoModels: ["default::grok-imagine-video"],
|
||||
textModels: ["default::gpt-5.5"],
|
||||
audioModels: ["default::gpt-4o-mini-tts"],
|
||||
models: [],
|
||||
imageModels: [],
|
||||
videoModels: [],
|
||||
textModels: [],
|
||||
audioModels: [],
|
||||
quality: "auto",
|
||||
size: "1:1",
|
||||
transparentBackground: "false",
|
||||
@@ -246,14 +238,17 @@ export function filterModelsByCapability(models: string[], capability?: ModelCap
|
||||
}
|
||||
|
||||
export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) {
|
||||
if (!capability) return config.models;
|
||||
return filterModelsByCapability(config.models, capability, config.channels);
|
||||
// 选项目录只从当前有效渠道重建,不能信任旧快照里残留的 config.models。
|
||||
// 这样旧版本内置模型、未绑定渠道的裸模型不会再次进入创作端。
|
||||
const models = modelOptionsFromChannels(config.channels);
|
||||
if (!capability) return models;
|
||||
return filterModelsByCapability(models, capability, config.channels);
|
||||
}
|
||||
|
||||
export function configuredModelMatchesCapability(config: AiConfig, model: string, capability?: ModelCapability) {
|
||||
const normalized = normalizeModelOptionValue(model, config.channels);
|
||||
if (!normalized || !config.models.includes(normalized)) return false;
|
||||
return capability ? selectableModelsByCapability(config, capability).includes(normalized) : true;
|
||||
if (!normalized) return false;
|
||||
return selectableModelsByCapability(config, capability).includes(normalized);
|
||||
}
|
||||
|
||||
function isAiConfigReady(config: AiConfig, model: string) {
|
||||
@@ -327,7 +322,7 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot | undefine
|
||||
models,
|
||||
model,
|
||||
imageModel: normalizeSelectedModel(config.imageModel || model, channels, imageModels),
|
||||
videoModel: normalizeSelectedModel(config.videoModel || "grok-imagine-video", channels, videoModels),
|
||||
videoModel: normalizeSelectedModel(config.videoModel, channels, videoModels),
|
||||
textModel: normalizeSelectedModel(config.textModel || model, channels, textModels),
|
||||
audioModel: normalizeSelectedModel(config.audioModel || defaultConfig.audioModel, channels, audioModels),
|
||||
audioVoice: config.audioVoice || defaultConfig.audioVoice,
|
||||
@@ -555,8 +550,9 @@ function isEmptyDefaultChannel(channel: ModelChannel) {
|
||||
const baseUrl = channel.baseUrl.trim().replace(/\/+$/, "");
|
||||
const defaultBaseUrl = defaultConfig.baseUrl.trim().replace(/\/+$/, "");
|
||||
if (baseUrl && baseUrl !== defaultBaseUrl) return false;
|
||||
const defaultModels = new Set((defaultConfig.channels[0]?.models || []).map(modelOptionName));
|
||||
return !channel.models.length || channel.models.every((model) => defaultModels.has(modelOptionName(model)));
|
||||
// 只清理旧版本写入浏览器的无密钥“默认渠道”和内置模型;没有 API Key 但已填写自定义模型时仍保留,
|
||||
// 让用户可以先保存模型目录再补充密钥,而不是把真实自定义配置误判为空。
|
||||
return !channel.models.length || channel.models.every((model) => LEGACY_DEFAULT_MODEL_NAMES.has(modelOptionName(model)));
|
||||
}
|
||||
|
||||
export function defaultBaseUrlForApiFormat(apiFormat: ApiCallFormat) {
|
||||
|
||||
@@ -4269,7 +4269,7 @@
|
||||
.assets-library-page .assets-library-grid > .asset-library-card > button:last-child { flex: 1 1 auto; }
|
||||
.assets-library-page .asset-library-card > .library-card-media, .assets-library-page .library-create-cover { aspect-ratio: 4 / 3; flex: 0 0 auto; }
|
||||
.assets-library-page .asset-library-card > .library-card-media > button:first-child { height: 100%; }
|
||||
.assets-library-page .asset-library-card > .library-card-media > button:first-child > :where(img, video) { height: 100%; aspect-ratio: auto; }
|
||||
.assets-library-page .asset-library-card > .library-card-media > button:first-child > :where(img, video, .cached-resource-image-shell) { height: 100%; aspect-ratio: auto; }
|
||||
.assets-library-page .library-create-card { padding-bottom: var(--space-4); }
|
||||
.project-assets-grid { align-items: start; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 18px; }
|
||||
.project-assets-grid > .asset-library-card { min-height: 0; height: auto; }
|
||||
@@ -8647,6 +8647,13 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Modal 关闭后会把焦点还给触发按钮;收窄该按钮的键盘焦点环,避免出现持续的粗灰边。 */
|
||||
.canvas-topbar-import-button:focus-visible {
|
||||
outline: 1px solid color-mix(in srgb, var(--foreground) 55%, transparent) !important;
|
||||
outline-offset: 1px !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:where([class*="card"], [class*="Card"]):focus:not(:focus-visible),
|
||||
:where([class*="card"], [class*="Card"]):has(:focus:not(:focus-visible)) {
|
||||
outline: none !important;
|
||||
@@ -8742,8 +8749,9 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
|
||||
.asset-picker-card.is-selected { box-shadow: inset 0 0 0 1px var(--library-selected-stroke); }
|
||||
.asset-picker-card.is-disabled { cursor: not-allowed; opacity: .46; }
|
||||
.asset-picker-card-media { position: relative; aspect-ratio: 4 / 3; overflow: hidden; background: var(--library-cover-surface); }
|
||||
.asset-picker-card-media > img, .asset-picker-card-media > video { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.asset-picker-card-media > img.is-contain { object-fit: contain; padding: 4px; }
|
||||
.cached-resource-image-shell { display: grid; width: 100%; height: 100%; place-items: center; }
|
||||
.asset-picker-card-media > img, .asset-picker-card-media > video, .asset-picker-card-media > .cached-resource-image-shell > img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.asset-picker-card-media > img.is-contain, .asset-picker-card-media > .cached-resource-image-shell > img.is-contain { object-fit: contain; padding: 4px; }
|
||||
.asset-picker-card-check { position: absolute; top: 9px; right: 9px; display: grid; width: 22px; height: 22px; place-items: center; border: 1px solid rgba(255, 255, 255, .72); border-radius: var(--r-xs); background: rgba(0, 0, 0, .28); color: transparent; backdrop-filter: blur(5px); }
|
||||
.asset-picker-card-check svg { width: 13px; height: 13px; }
|
||||
.asset-picker-card.is-selected .asset-picker-card-check { border-color: var(--control-check-bg); background: var(--control-check-bg); color: var(--control-check-fg); }
|
||||
|
||||
@@ -142,7 +142,18 @@ export type CanvasSkillSnapshot = {
|
||||
};
|
||||
|
||||
export type CanvasNodeMetadata = {
|
||||
importSource?: {
|
||||
provider: "libtv";
|
||||
projectUuid: string;
|
||||
nodeKey: string;
|
||||
batchId: string;
|
||||
sourceType?: string;
|
||||
styleAssetUuid?: string;
|
||||
styleVersionUuid?: string;
|
||||
styleName?: string;
|
||||
};
|
||||
content?: string;
|
||||
previewContent?: string;
|
||||
richText?: Record<string, unknown>;
|
||||
composerContent?: string;
|
||||
prompt?: string;
|
||||
|
||||
Reference in New Issue
Block a user