* 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 + 测试 + 构建通过, 浏览器冒烟待真实文本渠道)。
532 lines
30 KiB
TypeScript
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;
|
|
}
|