Files
ttstd-infinite-canvas/web/src/lib/plugins/builtin/editor/editor-asset-ingest.tsx
T
爱笑的毛毛虫 7ec51732f7 feat(editor): AI 编辑助手悬浮面板、插件权限体系与时间线渲染管线(M4–M6.3) (#411)
* feat(editor): 编辑器实施 M0-M1 - 剪辑入口占位与插件 SDK v2 垂直切片

- detail.tsx 新增剪辑成片视图(editor tab,常驻),概览页插入剪辑步骤卡
- plugin-types.ts 新增 v2 类型(EditorSlotKind/EditorPluginPermission/PluginManifestV2),修复权限数组交叉归约 bug
- editor-slot-registry.ts 插槽注册表(优先级排序、幂等注册、订阅机制)
- plugin-registry.ts 注册器 v1/v2 分支校验,v1 路径零改动
- editor-shell.tsx 垂直切片插件:timeline-panel 插槽真实渲染,验证插件链路全通
- 测试:插槽注册表 5 用例 + 命令测试骨架,v1 插件回归 21 例零回归

* feat(editor): 命令协议黄金文件 - editor-commands 纯函数 reducer + 8 个黄金 op

* feat(editor): 快照撤销历史 - editor-history 有界 200 层 undo/redo

* feat(editor): 命令状态机 store - dispatch/手势预览/1.5s 防抖保存/串行保存链

* fix(editor): createEditorStore 返回 zustand hook 类型修复 typecheck

* feat(editor): 手势 echo 最小件 - 时间线面板拖拽移动/裁剪 + store 接线与本地持久化

* docs(editor): M2 完成状态记录

* feat(editor): M3.1 时间线面板选中联动 - store 选中状态 + 片段高亮/点击取消

* feat(editor): M3.2 预览监视器 - preview-renderer 插槽 + 真实媒体帧/播放头/时间码

* feat(editor): M3.3 片段检查器 - inspector 插槽 + 属性编辑入队可撤销

* feat(editor): M3.4 asset-ingest 素材库插件(addClip 命令入轨)

* feat(editor): M3.4 补充 - host 上下文(项目信息+资产)、editor.tsx 接线、asset-ingest 纯函数、死代码清理

* feat(editor): M3.5 subtitle-tools - SRT import/export + rebuild from node snapshot

* feat(editor): M3.6 transcription - deterministic mock ASR to subtitle track

* feat(editor): M3.7 export - render plan + ffmpeg.wasm fallback export

* feat(editor): M3.8 - render all 8 editor slots in three-pane layout + ai-assistant placeholder

* docs(editor): M3 完成状态 - 8 插件 + 全插槽接线,冒烟待确认

* feat(editor): 编辑器媒体与时间线打磨 - 资产来源分组筛选、时间线全宽布局、拖拽末片段修复、OSS 降级本地存储、Concat 风格控制条与检查器

* fix(plugins): 合并上游后修复插件分类筛选对 v2 清单的类型兼容

* feat(editor): 时间线工具态与轨道操作打磨 - 工具按钮选中仅图标变色、监视器 Concat 式控制条、removeTrack 黄金命令与轨道行头移除按钮、maskClosable 迁移

* fix(api): 资源上传超 MaxBytesReader 上限时报中文大小限制而非英文裸 400

* fix(editor): 监视器控制条 - 修复播放按钮 JSX 模板闭合花括号缺失导致的编译错误

* feat(api): 本地媒体导入分片化对齐 Concat - >50MB 视频不再失败 - 分片会话三接口(POST开始/PUT单片/complete合并)、前端超50MB自动切8MB分片并失败整传重试、默认配额放宽(日200MB→2GB、总2GB→20GB)、UploadResourceFile 豁免单文件限

* feat(editor): 轨道行头新增可见性/静音开关按钮 - 复用现有图标资源,按轨道类型显隐;命令层新增 setTrackFlag 黄金命令(flag 白名单 fail-closed)并补单测

* docs(progress): 记录轨道可见/静音开关改动与复测项

* docs(editor): M4 转写与渲染任务设计 - 复用 Task 表与状态机、独立创建 API、worker 领取类型过滤、whisper HTTP provider(OpenAI 兼容 /v1/audio)

* fix(api): OSS 上传失败自动降级本地存储时 body 已被 Transport 关闭 - newOSSRequest 用 NopCloser 包装请求体,服务端提前返回(签名 403)时不再关闭调用方持有的文件,降级分支 Seek 重读生效;补回归测试

* docs(progress): 记录媒体导入根治(分片+OSS 降级修复)复测项

* feat(api): 视频播放副本转码 H.265→H.264 + variant=playback 服务与启动回填 - 上传视频后异步探测 codec,H.265/AV1 走 moov 快速探测并转码 H.264 播放副本;GET /api/user-data/...?variant=playback 就绪时服务副本(独立 ETag :pb),未就绪回退原件并撤销后缀防 304 黑屏;storeResource 触发、启动幂等回填存量;前端播放失败自动轮询副本就绪后刷新

* fix(editor): 轨道至少保留一条 - 不可移除轨道点击 X 时居中浮现原因提示(2.2s 自动消失, aria-live);修正可见性 toggle 传参 (visible===false 语义) ;轨道行内移除按钮改为常驻(不可移除时点击给提示)

* feat(timeline): M4.1 字幕转写后台任务 - whisper.cpp 本地执行 - 新增 timeline_transcription 任务类型,worker 按类型分叉独立执行器(不走计费与模型渠道);输入 resourceId+language,读取用户资源后 ffmpeg 预处理 16k 单声道 wav,POST 本地 whisper.cpp /inference,结果写回 segments+srt+language;未配置 CANVAS_WHISPER_BASE_URL 等失败均落明确终态;feature 门控;进度分阶段;含解析/SRT/快速失败单元测试

* feat(media): 素材卡片时长与媒体面板打磨 - 导入媒体解析时长(前端 media-metadata + 后端 project_asset durationMs 透出)随卡片展示;媒体面板/监视器/时间线相关样式细节收尾

* docs(progress): 阶段 C 监视器播放复检清单 - H.265 播放副本/真实播放驱动/时长链路待浏览器确认

* feat(timeline): M4.1 转写任务创建 API - POST /api/timeline/transcriptions - service CreateTimelineTranscriptionTask:IsDraining 守护 + feature 门控 + 资源归属与可转写类型校验 + 排队计 active 限额(含任务级/项目级存储估算);local/whisper.cpp 标记,任务不经过模型路由与计费;createTaskWithinStorageQuota 复用与 ErrActiveTaskLimit 用户可读映射;测试覆盖排队成功(owner/type/provider 断言)、跨用户资源拒绝、非音视频拒绝

* feat(timeline): M4.3 字幕转写前端接线 - 真实任务替换 mock - 新增 services/api/timeline-tasks.ts(POST /timeline/transcriptions,复用 apiClient+request);EditorTranscription 改为 创建任务→waitForGenerationTask 轮询(25min)→结果 segments 映射 SrtEntry[]→rebuildSubtitleClips 写入字幕轨道;实时展示 stage/progress,失败/空识别/无字幕轨道分别提示;素材选择支持音频与视频(取 storageKey 中的 resource id 提交)

* fix(media): 播放副本状态收敛与轮询终态 - 无ffmpeg/远端存储/编码不可处理落 none 避免重复探测;前端 none 停轮询回原生播放并加 processing 上限

* feat(timeline): M4.2 时间线渲染任务 - POST /api/timeline/renders 创建(buildRenderPlan 校验含可渲染媒体、配额入队、local/ffmpeg、60min 超时);worker 顶部按类型分叉 processTimelineRender;ffmpeg 合成产物写资源存储并经 ResultJSON 记 resourceId;render 计划/结果测试 PASS

* fix(resource): 转码原子抢占与 OSS 请求体加固 - ClaimPlaybackTranscode 空/none→processing 单实例抢占避免重复转码,runPlaybackTranscode defer recover 落 failed;If-Range 与带 :pb 后缀的 serveETag 比较;无请求体的 OSS 请求不再包 NopCloser(nil)(防 Go 1.26 body 探测 nil 解引用)

* style(editor): 修正字幕回写面板缩进

* docs(timeline): M4 设计文档按实现回写 - 单一 worker 池 + processClaimedTask 顶部按类型分叉(决策 3/4),端点统一为复数 /api/timeline/transcriptions 与 /api/timeline/renders(注册于 RegisterTaskRoutes),ResultJSON/feature gate/ffmpeg env 同步;Runbook M4 表格与状态行更新;auth.go API 前缀白名单补 timeline

* fix(service): 修复上游 v1.2.5 公告配图删除保护自锁与测试夹具缺口

上游 #385 资源引用走查器对公告配图/草稿做了硬性引用检查,导致三个问题:
- UpdateAnnouncement 在更新公告前对旧配图走查,必然命中当前公告自身引用而自锁,
  公告替换配图功能实际不可用;DiscardAnnouncementImage 同理被草稿自身引用阻塞。
  修复:ensureResourceHasNoBusinessReferences 支持忽略指定引用(当前公告/当前草稿),
  其余引用仍按原语义拦截。
- admin DeleteAdminResources 将公告草稿引用视为阻塞,但删除事务会级联清理草稿与
  资源绑定,service 层永不进入事务,回滚审计注入测试无法到达预期路径。修复:service
  预检时过滤公告草稿引用,与 repository 级联语义对齐。
- project_settings_test.go 夹具未建 ResourceReferenceSnapshot 走查的 Task/Session/
  Message/TaskLog/Result/Announcement/AnnouncementImageDraft 表,报 no such table。
  修复:夹具 AutoMigrate 补齐。

后端全量 go test ./... 通过。

* feat(editor): 成片导出接入后端渲染任务(M4.2 前端接线)- timeline-tasks 增 createTimelineRenderTask;editor-export 主路径改 POST /timeline/renders 轮询取 resourceId 内嵌预览/下载,ffmpeg.wasm 降级保留

* feat(editor): M5 插件权限执行(fail-closed)- plugin-permission-check 新建(8 插槽最小权限表/判定/断言);SlotStack 渲染前过滤缺权限插槽改诊断条;editor-shell manifest 补 timeline.command/export.run;测试 10 用例含 editor-shell 真实回归

* feat(editor): M6.1 AI 命令 schema + M6.2 时间线摘要 - ai-command-schema(parse/dry-run 校验/12-op 提示词契约/版本化+golden);timeline-summary(确定性中文摘要/折叠上限);各 20/10 用例全绿

* docs(progress): M6 质检清单 - M6.1 schema/M6.2 摘要标记完成(含测试计数),M6.3 待模型源确认;清理 M5.1 重复条目

* feat(editor): AI 命令 schema 支持只读问答 - commands 空数组合法化(无法执行/纯提问时 reasoning 直接作答),新增 AI_EDITING_MAX_COMMANDS=8 上限防失控编辑,提示词同步

* feat(editor): AI 助手重构为右上悬浮对话面板(M6.3)+ 素材入库原子化收尾

- editor-ai-assistant.tsx: 占位面板替换为 OpenAI Canvas 风格右上悬浮对话面板;
  面板内文本模型选择器(effective.textModels,选择即持久化并覆写请求模型),
  消息流 user/assistant/error/preview 四类气泡 + 思考中占位,空态引导示例 chips
  一键发送,底部输入条;顶部工具栏常驻 Sparkles 入口(aria-pressed 切换)。
  修复上一版重复/截断函数声明(appendToEntry 双定义、settleAnswer 三声明等)。
  宿主链路:buildAiEditingSystemPrompt(summarizeTimeline(project)) 喂上下文 →
  parseAiCommandPlan → validateAiCommandBatch dry-run(任一非法整批拒绝)→
  ≤3 条 dispatch 直执行 / >3 条预览确认卡片;宿主拒绝时 failTurn 回填 FAILED_MARK 可重试。
- editor-asset-ingest/editor-preview-monitor/detail/editor.tsx: 配套接线与打磨
  (编辑器顶栏 AI 入口、素材导入面板、预览监视、项目页顶栏整理)。
- backend: LinkProjectAsset 事务内原子创建资产 + 项目链接(OnConflict 幂等),
  资产首次入库不再预落库,失败整体回滚,消除"有资产无链接"孤儿记录。
- docs: pending-test.mdx / runbook M6.3 状态更新(tsc + 测试 + 构建通过,
  浏览器冒烟待真实文本渠道)。
2026-09-04 14:03:33 +08:00

532 lines
30 KiB
TypeScript

// 资产导入(editor-shell 预设插件贡献 asset-ingest 插槽,M3.4)。
// 展示项目资产库,按来源区分「本地上传」与「项目素材」;点击资产 → makeClipFromAsset
// → dispatch addClip 加入时间线(添加到匹配 kind 的轨道末尾)。资产是
// "仅时间线作用域"直连媒体(nodeId=asset:<id>)。导入链路:uploadResourceFile →
// linkProjectAsset(后端按资源元数据合成资产记录)→ refreshAssets。
import { useEffect, useRef, useState } from "react";
import { Boxes, ChevronDown, ChevronRight, Clapperboard, Film, FolderOpen, HardDrive, Image as ImageIcon, Loader2, Music2, Plus } from "lucide-react";
import { useEditorHostContext, useEditorStoreContext } from "@/components/editor/editor-context";
import { defaultAssetCategoryForKind } from "@/lib/asset-category";
import { probeMediaDurationMs } from "@/lib/media-metadata";
import { makeClipFromAsset } from "@/lib/timeline/asset-ingest";
import { DEFAULT_AUDIO_TRACK_ID, DEFAULT_SUBTITLE_TRACK_ID, DEFAULT_VIDEO_TRACK_ID } from "@/lib/timeline/timeline-tracks";
import { linkProjectAsset } from "@/services/api/projects";
import { uploadResourceFile, type ResourceUploadMeta } from "@/services/api/resources";
import { resolveMediaUrl } from "@/services/file-storage";
import type { ProjectAsset } from "@/services/api/projects";
import type { TimelineProject } from "@/types/timeline";
const MEDIA_ACCEPT = "video/*,audio/*,image/*";
/** 库内重复判定用的归一键(文件名+媒体类型)。 */
function assetDedupeKey(title: string | undefined, mediaType: string): string {
return `${(title || "").trim().toLowerCase()}|${mediaType}`;
}
/** 毫秒 → m:ss 时长文案。 */
function formatDurationMs(ms: number | undefined): string {
if (!ms || ms <= 0) return "";
const total = Math.round(ms / 1000);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
const IMAGE_RE = /\.(png|jpe?g|webp|gif|avif)$/i;
const AUDIO_RE = /\.(mp3|wav|m4a|ogg|flac|aac)$/i;
const MEDIA_RE = /\.(mp4|mov|webm|mkv|m4v|avi)$/i;
/** 本地上传标记(assetFromUploadedResource 写入 payload.data.source)。 */
const SOURCE_UPLOADED = "uploaded";
type AssetFilter = "all" | "project" | "uploaded";
const FILTER_TABS: { id: AssetFilter; label: string }[] = [
{ id: "all", label: "全部" },
{ id: "project", label: "项目素材" },
{ id: "uploaded", label: "本地上传" },
];
/** 按 MIME 与扩展名推断媒体 kind;非媒体文件返回 null。 */
function kindFromFile(file: File): "image" | "video" | "audio" | null {
if (file.type.startsWith("video/")) return "video";
if (file.type.startsWith("audio/")) return "audio";
if (file.type.startsWith("image/")) return "image";
if (IMAGE_RE.test(file.name)) return "image";
if (AUDIO_RE.test(file.name)) return "audio";
if (MEDIA_RE.test(file.name)) return "video";
return null;
}
function trackIdForKind(kind: string, project: TimelineProject): string {
const track = project.tracks.find((t) => t.kind === kind);
if (track) return track.id;
if (kind === "video" || kind === "image") return project.tracks.find((t) => t.kind === "video")?.id ?? DEFAULT_VIDEO_TRACK_ID;
if (kind === "audio") return project.tracks.find((t) => t.kind === "audio")?.id ?? DEFAULT_AUDIO_TRACK_ID;
return DEFAULT_SUBTITLE_TRACK_ID;
}
function trackEndMs(project: TimelineProject, trackId: string): number {
return project.clips.filter((c) => c.trackId === trackId).reduce((max, c) => Math.max(max, c.startMs + c.durationMs), 0);
}
function AssetIcon({ mediaType }: { mediaType: string }) {
const cls = "size-4";
if (mediaType === "video") return <Film className={cls} />;
if (mediaType === "audio") return <Music2 className={cls} />;
if (mediaType === "image") return <ImageIcon className={cls} />;
return <Clapperboard className={cls} />;
}
/** 列表行小缩略图:图片媒体显示真实画面;视频/音频无法在 <img> 解码,
* 统一渲染类型色块+图标占位(不再出现破图),视频缩略图由展开卡片提供。 */
function AssetThumb({ asset }: { asset: ProjectAsset }) {
const isImage = asset.mediaType === "image";
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!isImage || !asset.storageKey) return;
let alive = true;
resolveMediaUrl(asset.storageKey)
.then((resolved) => alive && setUrl(resolved ?? null))
.catch(() => alive && setUrl(null));
return () => {
alive = false;
};
}, [asset.storageKey, isImage]);
if (url) {
return (
<div className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-md bg-[var(--director-control-hover)]">
<img src={url} alt="" className="size-full object-cover" />
</div>
);
}
const kind =
asset.mediaType === "video"
? { icon: <Film className="size-4" />, tone: "text-[var(--director-accent)]", tile: "bg-[var(--director-accent)]/15" }
: asset.mediaType === "audio"
? { icon: <Music2 className="size-4" />, tone: "text-[var(--director-dock-fg)]/70", tile: "bg-[var(--director-control-hover)]" }
: { icon: <ImageIcon className="size-4" />, tone: "text-[var(--director-dock-fg)]/60", tile: "bg-[var(--director-control-hover)]" };
return (
<div className={`grid size-9 shrink-0 place-items-center overflow-hidden rounded-md ${kind.tile}`}>
<span className={kind.tone}>{kind.icon}</span>
</div>
);
}
/** 尝试用 <video> 抓取视频首帧作为封面 dataURL;失败返回 null(保持图标占位)。 */
function useVideoPoster(url: string | null): string | null {
const [poster, setPoster] = useState<string | null>(null);
useEffect(() => {
if (!url) {
setPoster(null);
return;
}
let alive = true;
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.preload = "metadata";
video.src = url;
const seekToHead = () => {
if (!alive || video.readyState < 1) return;
try {
video.currentTime = Math.min(0.05, Number.isFinite(video.duration) ? video.duration : 0.05);
} catch {
/* 忽略跨域/编解码异常 */
}
};
const grabFrame = () => {
if (!alive || video.readyState < 2 || video.videoWidth === 0) return;
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL("image/jpeg", 0.7);
if (dataUrl.length > 100) setPoster(dataUrl);
} catch {
/* canvas 跨域污染等:放弃自动封面 */
}
};
video.addEventListener("loadeddata", seekToHead);
video.addEventListener("seeked", grabFrame);
return () => {
alive = false;
video.removeAttribute("src");
video.load();
};
}, [url]);
return poster;
}
/** 展开卡片预览:图片直接展示;视频给出自动首帧封面 + 原生播放器;音频为说明。 */
function MediaPreview({ asset }: { asset: ProjectAsset }) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!asset.storageKey) return;
let alive = true;
resolveMediaUrl(asset.storageKey)
.then((resolved) => alive && setUrl(resolved ?? null))
.catch(() => alive && setUrl(null));
return () => {
alive = false;
};
}, [asset.storageKey]);
const poster = useVideoPoster(asset.mediaType === "video" ? url : null);
if (asset.mediaType === "image") {
return url ? <img src={url} alt="" className="h-28 w-full rounded-md object-cover" /> : <div className="h-28 animate-pulse rounded-md bg-[var(--director-control-hover)]" />;
}
if (asset.mediaType === "video") {
return (
<div className="relative overflow-hidden rounded-md bg-black">
<video
src={url ?? undefined}
poster={poster ?? undefined}
preload="metadata"
controls
playsInline
className="aspect-video w-full"
/>
{!url ? <div className="aspect-video w-full animate-pulse bg-[var(--director-control-hover)]" /> : null}
</div>
);
}
return (
<div className="flex h-16 items-center justify-center gap-2 rounded-md bg-[var(--director-control-hover)] text-[var(--director-dock-fg)]/60">
<Music2 className="size-4" />
<span className="text-[10px]">音频素材 · 点击下方按钮加入时间线</span>
</div>
);
}
function SourceBadge({ source }: { source: string }) {
const uploaded = source === SOURCE_UPLOADED;
return (
<span
className={`rounded px-1 py-px text-[9px] leading-none ${
uploaded
? "bg-[var(--director-control-hover)] text-[var(--director-dock-fg-strong)]"
: "bg-[var(--director-control-hover)] text-[var(--director-dock-fg)]/70"
}`}
>
{uploaded ? "本地上传" : "项目素材"}
</span>
);
}
export function EditorAssetIngest() {
const { projectId, assets, refreshAssets } = useEditorHostContext();
const { project, dispatch } = useEditorStoreContext();
const [added, setAdded] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [importNote, setImportNote] = useState<string | null>(null);
useEffect(() => {
if (!importNote) return;
const timer = window.setTimeout(() => setImportNote(null), 3200);
return () => window.clearTimeout(timer);
}, [importNote]);
const [dragOver, setDragOver] = useState(false);
const [filter, setFilter] = useState<AssetFilter>("all");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [openGroups, setOpenGroups] = useState<{ uploaded: boolean; project: boolean }>({ uploaded: true, project: true });
const inputRef = useRef<HTMLInputElement | null>(null);
const lastAddKey = useRef<string | null>(null);
const lastAddAt = useRef(0);
if (!project) return null;
const importFiles = async (fileList: FileList | File[]) => {
const media = Array.from(fileList)
.map((file) => ({ file, kind: kindFromFile(file) }))
.filter((x): x is { file: File; kind: "image" | "video" | "audio" } => x.kind !== null);
if (media.length === 0) {
setImportError("仅支持视频、音频与图片文件");
return;
}
setImporting(true);
setImportError(null);
setImportNote(null);
let okCount = 0;
let firstErrorMsg: string | null = null;
const failedNames: string[] = [];
try {
// 去重:同名同类型视为同一素材。库内已有或本批内重复的跳过,避免
// 再次上传同一大文件生成重复 asset(历史上重复导入会把素材堆积成多条)。
const seen = new Set<string>();
const toImport = media.filter(({ file, kind }) => {
const key = assetDedupeKey(file.name, kind);
const known = assets.some((a) => assetDedupeKey(a.title, a.mediaType) === key);
const duplicated = seen.has(key);
seen.add(key);
return !known && !duplicated;
});
const skipped = media.length - toImport.length;
// 逐文件导入:单文件失败不中断整批,汇总失败数提示。
for (const { file, kind } of toImport) {
try {
// 上传前探测真实时长(视频/音频),随 meta 入库供时间线片段使用。
const durationMs = await probeMediaDurationMs(file);
const resource = await uploadResourceFile(file, kind, durationMs !== undefined ? { durationMs } : undefined);
if (resource.status === "failed") {
failedNames.push(file.name);
if (!firstErrorMsg) firstErrorMsg = resource.error ? `「${file.name}」${resource.error}` : null;
continue;
}
let linked = false;
try {
await linkProjectAsset(projectId, { assetId: resource.id, category: defaultAssetCategoryForKind(kind), title: file.name });
linked = true;
} catch {
// 链接偶发失败(网络抖动/后端竞态)时重试一次,避免资源已入库却未挂到项目下。
linked = await linkProjectAsset(projectId, { assetId: resource.id, category: defaultAssetCategoryForKind(kind), title: file.name })
.then(() => true)
.catch(() => false);
}
if (!linked) {
// 两次挂载都失败(asset 可能已创建但未挂上项目):不删除,提示重试——重试走幂等路径可补挂。
failedNames.push(file.name);
if (!firstErrorMsg) firstErrorMsg = `「${file.name}」已上传但挂载到项目失败,请重试`;
continue;
}
okCount += 1;
} catch (err) {
failedNames.push(file.name);
const detail = extractApiMessage(err);
if (!firstErrorMsg) firstErrorMsg = detail ? `「${file.name}」${detail}` : `「${file.name}」导入失败`;
}
}
if (okCount > 0) {
await refreshAssets();
setImportNote(skipped > 0 ? `已导入 ${okCount} 个,跳过 ${skipped} 个重复文件` : `已导入 ${okCount} 个媒体`);
} else if (skipped > 0 && failedNames.length === 0) {
setImportNote(`媒体库中已有同名素材,跳过 ${skipped} 个重复文件`);
}
if (firstErrorMsg) {
setImportError(firstErrorMsg);
} else if (failedNames.length > 0) {
const failedText = failedNames.length === 1 ? `「${failedNames[0]}」` : `${failedNames.length} 个文件`;
setImportError(`${failedText}导入失败,请重试`);
}
} finally {
setImporting(false);
}
};
// 双击“添加到时间线”会连续 dispatch 两次,产生两个同一素材的相邻片段;用时间戳守卫拦截 1.5s 内的重复添加。
const addToTimeline = (asset: ProjectAsset) => {
const now = Date.now();
if (lastAddKey.current === asset.id && now - lastAddAt.current < 1500) return;
lastAddKey.current = asset.id;
lastAddAt.current = now;
const kind = asset.mediaType === "video" ? "video" : asset.mediaType === "audio" ? "audio" : asset.mediaType === "image" ? "image" : null;
if (!kind) return;
const trackId = trackIdForKind(kind, project);
const startMs = trackEndMs(project, trackId);
const clip = makeClipFromAsset(asset, {
trackId,
startMs,
defaultDurationMs: kind === "image" ? 3_000 : 5_000,
});
if (!clip) return;
dispatch({ op: "addClip", payload: { clip } });
setAdded(asset.id);
setTimeout(() => setAdded(null), 1200);
};
const uploadedAssets = assets.filter((a) => a.source === SOURCE_UPLOADED);
const projectAssets = assets.filter((a) => a.source !== SOURCE_UPLOADED);
const groups: { id: "uploaded" | "project"; label: string; icon: typeof HardDrive; items: ProjectAsset[] }[] = [
{ id: "uploaded", label: "本地上传", icon: HardDrive, items: uploadedAssets },
{ id: "project", label: "项目素材", icon: Boxes, items: projectAssets },
];
const visibleGroups = groups.filter((g) => filter === "all" || g.id === filter);
const totalCount = assets.length;
return (
<div
className={`flex h-full min-h-0 flex-col bg-[var(--director-sequencer-surface)] ${dragOver ? "ring-2 ring-inset ring-[var(--director-accent)]" : ""}`}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
void importFiles(e.dataTransfer.files);
}}
>
{/* 标题行:媒体库 + 导入按钮 */}
<div className="flex items-center gap-1.5 p-2 pb-1">
<input
ref={inputRef}
type="file"
accept={MEDIA_ACCEPT}
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) void importFiles(e.target.files);
e.target.value = "";
}}
/>
<span className="text-xs font-semibold text-[var(--director-dock-fg-strong)]">媒体库</span>
<span className="rounded-full bg-[var(--director-control-hover)] px-1.5 text-[9px] tabular-nums text-[var(--director-dock-fg)]/70">{totalCount}</span>
<div className="flex-1" />
<button
type="button"
disabled={importing}
onClick={() => inputRef.current?.click()}
className="flex h-7 items-center gap-1.5 rounded-md border border-dashed border-[var(--director-sequencer-border)] px-2.5 text-[11px] font-medium text-[var(--director-dock-fg-strong)] transition-colors hover:border-[var(--director-accent)] hover:bg-[var(--director-control-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
{importing ? <Loader2 className="size-3.5 animate-spin" /> : <FolderOpen className="size-3.5" />}
{importing ? "导入中…" : "导入媒体"}
</button>
</div>
{/* 来源筛选:全部 / 项目素材 / 本地上传 */}
<div className="flex items-center gap-0.5 px-2 pb-1">
{FILTER_TABS.map((tab) => (
<button
key={tab.id}
type="button"
aria-pressed={filter === tab.id}
onClick={() => setFilter(tab.id)}
className={`h-6 rounded-md px-2 text-[10px] transition-colors ${
filter === tab.id
? "bg-[var(--director-dock-active-surface)] text-[var(--director-dock-fg-strong)]"
: "text-[var(--director-dock-fg)] hover:bg-[var(--director-control-hover)]"
}`}
>
{tab.label}
</button>
))}
</div>
{importError ? <p className="px-2 pb-1 text-[10px] text-[var(--director-danger)]">{importError}</p> : null}
{importNote ? <p className="px-2 pb-1 text-[10px] text-[var(--director-success)]">{importNote}</p> : null}
<div className="director-scroll min-h-0 flex-1 overflow-y-auto p-1.5">
{assets.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
<p className="text-xs text-[var(--director-dock-fg)]/60">项目暂无资产</p>
<p className="max-w-[180px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">点击上方导入媒体,或将文件拖入此区域</p>
</div>
) : visibleGroups.every((g) => g.items.length === 0) ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
<p className="text-xs text-[var(--director-dock-fg)]/60">
{filter === "uploaded" ? "暂无本地上传的媒体" : filter === "project" ? "暂无项目素材" : "项目暂无资产"}
</p>
<p className="max-w-[180px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">点击上方导入媒体,或将文件拖入此区域</p>
</div>
) : (
<div className="flex flex-col gap-1">
{visibleGroups.map((group) => {
if (group.items.length === 0) return null;
const open = openGroups[group.id];
return (
<div key={group.id} className="flex flex-col">
<button
type="button"
aria-expanded={open}
onClick={() => setOpenGroups((s) => ({ ...s, [group.id]: !open }))}
className="group flex h-7 w-full items-center gap-1.5 rounded-md px-1 text-left transition-colors hover:bg-[var(--director-control-hover)]"
>
<span
className={`grid size-4 shrink-0 place-items-center rounded-[5px] transition-colors ${
open ? "bg-[var(--director-accent)]/15 text-[var(--director-accent)]" : "bg-[var(--director-control-hover)] text-[var(--director-dock-fg)]/55 group-hover:text-[var(--director-dock-fg-strong)]"
}`}
>
<ChevronRight className={`size-3 transition-transform duration-150 ease-out ${open ? "rotate-90" : ""}`} />
</span>
<group.icon className={`size-3.5 shrink-0 transition-colors ${open ? "text-[var(--director-accent)]" : "text-[var(--director-dock-fg)]/60"}`} />
<span className="truncate text-[11px] font-medium text-[var(--director-dock-fg-strong)]">{group.label}</span>
<span className="ml-auto rounded-full bg-[var(--director-control-hover)] px-1.5 text-[9px] tabular-nums text-[var(--director-dock-fg)]/70">{group.items.length}</span>
</button>
{open ? (
<ul className="flex flex-col">
{group.items.map((asset) => {
const expanded = expandedId === asset.id;
const source = asset.source || "";
return (
<li key={asset.id}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpandedId(expanded ? null : asset.id)}
title={asset.title || asset.storageKey}
className={`group flex w-full items-center gap-2 rounded-md px-1 py-[3px] text-left transition-colors ${
added === asset.id ? "bg-[var(--director-accent)]/15" : "hover:bg-[var(--director-control-hover)]"
}`}
>
<AssetThumb asset={asset} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] text-[var(--director-dock-fg-strong)]">{asset.title || asset.storageKey}</span>
<span className="mt-0.5 flex items-center gap-1 text-[9px] text-[var(--director-dock-fg)]/60">
<span className="uppercase">{asset.mediaType}</span>
{asset.durationMs ? <span className="tabular-nums opacity-80">{formatDurationMs(asset.durationMs)}</span> : null}
<SourceBadge source={source} />
</span>
</span>
<span
className={`grid size-5 shrink-0 place-items-center rounded-[5px] transition-colors ${
expanded ? "bg-[var(--director-accent)]/15 text-[var(--director-accent)]" : "text-[var(--director-dock-fg)]/40 group-hover:text-[var(--director-dock-fg)]/80"
}`}
>
<ChevronDown className={`size-3.5 transition-transform duration-150 ease-out ${expanded ? "rotate-180" : ""}`} />
</span>
</button>
{expanded ? (
<div className="mx-1 mb-1 ml-10 rounded-md bg-[var(--director-control-hover)] p-2">
<MediaPreview asset={asset} />
<dl className="mt-2 grid grid-cols-2 gap-x-3 gap-y-1 text-[10px]">
<dt className="text-[var(--director-dock-fg)]/60">媒体类型</dt>
<dd className="truncate text-right capitalize text-[var(--director-dock-fg-strong)]">{asset.mediaType}</dd>
<dt className="text-[var(--director-dock-fg)]/60">分类</dt>
<dd className="truncate text-right text-[var(--director-dock-fg-strong)]">{asset.category}</dd>
<dt className="text-[var(--director-dock-fg)]/60">来源</dt>
<dd className="text-right text-[var(--director-dock-fg-strong)]">{source === SOURCE_UPLOADED ? "本地上传" : "项目素材"}</dd>
{asset.durationMs ? (
<>
<dt className="text-[var(--director-dock-fg)]/60">时长</dt>
<dd className="text-right tabular-nums text-[var(--director-dock-fg-strong)]">{formatDurationMs(asset.durationMs)}</dd>
</>
) : null}
</dl>
{asset.previewText ? <p className="mt-1 line-clamp-2 text-[10px] leading-relaxed text-[var(--director-dock-fg)]/60">{asset.previewText}</p> : null}
<button
type="button"
onClick={() => addToTimeline(asset)}
className="mt-2 flex h-7 w-full items-center justify-center gap-1 rounded-md bg-[var(--director-accent)] text-[11px] font-medium text-[var(--director-on-accent)] transition-colors hover:bg-[var(--director-accent-hover)]"
>
<Plus className="size-3.5" />
添加到时间线
</button>
</div>
) : null}
</li>
);
})}
</ul>
) : null}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
/** 从 Axios 错误中提取后端业务信息({ code, data, msg } 的 msg),无则返回 null。 */
function extractApiMessage(err: unknown): string | null {
if (typeof err !== "object" || err === null) return null;
const anyErr = err as { response?: { data?: { msg?: string } } };
const msg = anyErr.response?.data?.msg;
if (typeof msg === "string" && msg.trim()) return msg.trim();
return null;
}