feat(editor): 素材面板改造与视频 codec 存量重判 (#419)

* refactor(editor): 画布素材统一到项目素材

- 移除「画布素材」分组、筛选页签、来源徽标与详情「来源」行
- 画布产物(source=canvas)并入「项目素材」组,与普通项目素材一致展示
- 数据层仍写入 canvas 来源标记,仅供追溯,不再驱动任何 UI 分组

* fix(editor): 启动回填重判存量 none 视频的 codec

- Repository.PlaybackNoneVideos: 取旧规则遗留(provider=local, playback_status='none')的本地视频
- BackfillPlaybackTranscodes 追加有界重判(每次启动至多 20 条最旧行): H.264 保持 none,
  H.265/MPEG-4 Part 2 自动触发转码, 使 codec 判定规则变更覆盖规则变更前已导入的文件
- 新增守护测试 TestBackfillRejudgesLegacyNoneVideos: 存量 none 的 H.264 保持、mp4v 转码落 failed

* feat(editor): 素材展开改为浮层详情卡,列表不再被挤压

- 移除行内展开块:此前点击素材行会在分组内内联撑开预览+详情+按钮,把列表下方素材挤走
- 新增 AssetDetailSheet 浮层:覆盖在列表视口上方,列表 DOM 原位不动、滚动位置不丢
- 浮层自带头部(类型图标+标题+关闭)、滚动内容区(预览/元数据/描述)与固定底部「添加到时间线」
- 键盘可达:Esc 或关闭按钮退出;聚焦管理 + aria-label + 尊重 prefers-reduced-motion
This commit is contained in:
爱笑的毛毛虫
2026-09-05 16:09:34 +08:00
committed by GitHub
parent add65cb8d0
commit ad0d2bf818
5 changed files with 220 additions and 58 deletions

View File

@@ -1073,6 +1073,20 @@ func (r *Repository) PlaybackPendingVideos(limit int) ([]model.Resource, error)
return resources, err
}
// PlaybackNoneVideos 返回存量本地视频中旧逻辑遗留、停在 none 的行
// (规则变更前 H.265/MPEG-4 Part 2 曾被误判为浏览器可播并落 none)。
// 服务启动回填时对它们重新按 codec 判定,让判定规则变更覆盖规则变更前已导入的文件。
func (r *Repository) PlaybackNoneVideos(limit int) ([]model.Resource, error) {
var resources []model.Resource
if limit <= 0 || limit > 100 {
limit = 20
}
err := r.db.Where("kind = ? AND status = ? AND provider = ? AND playback_status = ?",
"video", model.ResourceStatusReady, "local", model.PlaybackStatusNone).
Order("created_at asc").Limit(limit).Find(&resources).Error
return resources, err
}
// ClaimPlaybackTranscode 原子地把待判定(空/none)视频置为 processing,返回是否抢占成功。
// 多实例或多 goroutine 并发转同一资源时仅一个能成功置位,其余返回 false 直接放弃,
// 避免重复转码同一份文件。

View File

@@ -301,7 +301,8 @@ func (s *Service) OpenResourcePlaybackRange(userID string, resourceID string) (*
}
// BackfillPlaybackTranscodes 在服务启动后扫描存量本地视频:未判定 codec 的补判定,
// H.265 触发转码、H.264 标记 none。幂等:maybeStartPlaybackTranscode 先置
// H.265/MPEG-4 Part 2 触发转码、H.264 标记 none;再对旧规则遗留的 none 行做一次
// 有界重判(见 PlaybackNoneVideos)。幂等:maybeStartPlaybackTranscode 先置
// processing/none 再入库,重复扫描不会重复转码。
func (s *Service) BackfillPlaybackTranscodes() {
// 上次进程可能崩溃在转码中途(状态卡 processing),先重置为待判定。
@@ -309,10 +310,20 @@ func (s *Service) BackfillPlaybackTranscodes() {
for {
resources, err := s.repo.PlaybackPendingVideos(20)
if err != nil || len(resources) == 0 {
return
break
}
for i := range resources {
s.maybeStartPlaybackTranscode(&resources[i])
}
}
// 旧版本曾把 H.265/MPEG-4 Part 2 误判为浏览器可播并落 none;对存量 none 行
// 做一次有界重判(H.264 保持 none,H.265/MPEG-4 Part 2 触发转码),使 codec
// 判定规则的变更覆盖规则变更前已导入的文件。每次启动最多重判 20 条最旧行,
// 天然收敛且不会重复转码(claim 原子地把 none → processing)。
legacy, err := s.repo.PlaybackNoneVideos(20)
if err == nil {
for i := range legacy {
s.maybeStartPlaybackTranscode(&legacy[i])
}
}
}

View File

@@ -3,8 +3,12 @@ package service
import (
"encoding/binary"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"infinite-canvas/backend/internal/model"
)
// stsdBox 构造仅含 first-sample-entry fourcc 的最小 stsd box(置于 moov 切片内)。
@@ -63,3 +67,77 @@ func TestProbeVideoCodecReadsRealFile(t *testing.T) {
t.Errorf("probeVideoCodec = %q, want h265", got)
}
}
// TestBackfillRejudgesLegacyNoneVideos 覆盖修复:判定规则变更前(H.265/MPEG-4 Part 2
// 曾被误判可播)落 none 的存量本地视频,启动回填必须重新按 codec 判定——
// H.264 保持 none(幂等),MPEG-4 触发转码并最终落到 failed/ready 终态。
func TestBackfillRejudgesLegacyNoneVideos(t *testing.T) {
service, db := newProjectAssetLinkTestService(t)
dataDir := t.TempDir()
service.dataDir = dataDir
seedLegacy := func(id, fourcc string) {
t.Helper()
stsd := stsdBoxWithFourcc(fourcc)
moov := make([]byte, 8+len(stsd))
binary.BigEndian.PutUint32(moov[0:4], uint32(len(moov)))
copy(moov[4:8], "moov")
copy(moov[8:], stsd)
ftyp := make([]byte, 16)
binary.BigEndian.PutUint32(ftyp[0:4], 16)
copy(ftyp[4:8], "ftyp")
copy(ftyp[8:12], "isom")
full := append(append([]byte{}, ftyp...), moov...)
rel := filepath.Join("clips", id+".mp4")
p := filepath.Join(dataDir, "resources", filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, full, 0o644); err != nil {
t.Fatal(err)
}
res := model.Resource{
ID: id, UserID: "user-1", Kind: "video", Status: model.ResourceStatusReady,
Provider: "local", ObjectKey: rel, PlaybackStatus: model.PlaybackStatusNone,
}
if err := db.Create(&res).Error; err != nil {
t.Fatal(err)
}
}
seedLegacy("legacy-h264", "avc1")
seedLegacy("legacy-mpeg4", "mp4v")
service.BackfillPlaybackTranscodes()
var h264 model.Resource
if err := db.First(&h264, "id = ?", "legacy-h264").Error; err != nil {
t.Fatal(err)
}
if h264.PlaybackStatus != model.PlaybackStatusNone {
t.Fatalf("H.264 存量 none 行被错误改判为 %q", h264.PlaybackStatus)
}
// MPEG-4 行应被抢占转码。fake mp4 不含真实视频流,ffmpeg 解码必败 → failed;
// 若某环境恰有同名 ready 副本则也接受。轮询直到终态,避免 goroutine 竞态。
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Log("无 ffmpeg,跳过 MPEG-4 终态断言")
return
}
var mp4v model.Resource
deadline := time.Now().Add(10 * time.Second)
for {
if err := db.First(&mp4v, "id = ?", "legacy-mpeg4").Error; err != nil {
t.Fatal(err)
}
if mp4v.PlaybackStatus == model.PlaybackStatusFailed || mp4v.PlaybackStatus == model.PlaybackStatusReady {
break
}
if time.Now().After(deadline) {
t.Fatalf("MPEG-4 存量行未达终态,停在 %q(error=%q)", mp4v.PlaybackStatus, mp4v.PlaybackError)
}
time.Sleep(100 * time.Millisecond)
}
if mp4v.PlaybackStatus != model.PlaybackStatusFailed {
t.Fatalf("fake mp4v 应转码失败落 failed,实际 %q", mp4v.PlaybackStatus)
}
}

View File

@@ -937,8 +937,8 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
已实施,代码/单测/编译验证通过(后端 go vet/test、前端 tsc 全绿),需浏览器最终确认:
- [ ] 导入 MPEG-4 Part 2(mp4v)视频(如 07ee…mp4 sprite/header damaged 样例)后拖入时间线:不再出现"正在生成兼容版本"提示闪烁死循环;损坏文件转码失败后显示终止性文案(不再反复回退重试);正常 mp4v 文件自动生成 H.264 副本并可播放。
- [ ] 打开剪辑器进入已含画布产物(selected+ready 的 storyboard/action_board/分镜/导出成片)的项目:素材面板自动出现"画布素材"分组与对应素材;再次进入/刷新不产生重复素材。
- [ ] 素材面板"画布素材/本地上传/项目素材"三个页签与分组切换、来源徽章与详情来源文本正确。
- [ ] 存量回归(backend 重启生效):重启后端后,旧规则遗留的 none 存量视频被自动重判——H.264 保持可播;损坏 mp4v(如 c326/07ee…)自动尝试转码并以失败终态呈现终止性文案,不再直接抛"视频编码此浏览器暂不支持"。
- [ ] 打开剪辑器进入已含画布产物(selected+ready 的 storyboard/action_board/分镜/导出成片)的项目:素材自动并入"项目素材"组展示(不再单列"画布素材"分组/页签/来源徽章),再次进入/刷新不产生重复素材。
- [ ] 项目素材库页与剪辑器素材面板对本轮新同步的画布素材均可见;下载/删除画布素材不影响对应画布产物资源。
## 画布默认框选与移动期性能优化

View File

@@ -5,7 +5,7 @@
// 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 { Boxes, Check, ChevronDown, ChevronRight, Clapperboard, Film, FolderOpen, HardDrive, Image as ImageIcon, Loader2, Music2, Plus, X } from "lucide-react";
import { useEditorHostContext, useEditorStoreContext } from "@/components/editor/editor-context";
import { defaultAssetCategoryForKind } from "@/lib/asset-category";
@@ -34,17 +34,15 @@ 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)。 */
/** 本地上传标记(assetFromUploadedResource 写入 payload.data.source)。画布产物同步时写入 canvas,仅作数据追溯,面板不再单列分组。 */
const SOURCE_UPLOADED = "uploaded";
const SOURCE_CANVAS = "canvas";
type AssetFilter = "all" | "project" | "uploaded" | "canvas";
type AssetFilter = "all" | "project" | "uploaded";
const FILTER_TABS: { id: AssetFilter; label: string }[] = [
{ id: "all", label: "全部" },
{ id: "project", label: "项目素材" },
{ id: "uploaded", label: "本地上传" },
{ id: "canvas", label: "画布素材" },
];
/** 按 MIME 与扩展名推断媒体 kind;非媒体文件返回 null。 */
@@ -200,20 +198,96 @@ function MediaPreview({ asset }: { asset: ProjectAsset }) {
</div>
);
}
function SourceBadge({ source }: { source: string }) {
const uploaded = source === SOURCE_UPLOADED;
const canvas = source === SOURCE_CANVAS;
/** 展开详情浮层:覆盖在素材列表上方(列表保持原位不被挤压、滚动位置不变),
* 自带头部(标题+关闭)、可滚动内容区与固定底部操作;Esc 或关闭按钮退出。 */
function AssetDetailSheet({
asset,
added,
onAdd,
onClose,
}: {
asset: ProjectAsset;
added: boolean;
onAdd: () => void;
onClose: () => void;
}) {
const closeRef = useRef<HTMLButtonElement | null>(null);
const [shown, setShown] = useState(false);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setShown(true);
return;
}
const raf = window.requestAnimationFrame(() => setShown(true));
return () => window.cancelAnimationFrame(raf);
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
closeRef.current?.focus();
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
return (
<span
className={`rounded px-1 py-px text-[9px] leading-none ${
uploaded || canvas
? "bg-[var(--director-control-hover)] text-[var(--director-dock-fg-strong)]"
: "bg-[var(--director-control-hover)] text-[var(--director-dock-fg)]/70"
<div
role="dialog"
aria-label={`${asset.title || "素材"} 详情`}
className={`absolute inset-1.5 z-10 flex min-h-0 flex-col overflow-hidden rounded-lg border border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-[opacity,transform] duration-150 ease-out ${
shown ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0"
}`}
>
{uploaded ? "本地上传" : canvas ? "画布素材" : "项目素材"}
</span>
<div className="flex items-center gap-2 border-b border-[var(--director-sequencer-border)] px-2.5 py-2">
<span className="grid size-5 shrink-0 place-items-center rounded-[5px] bg-[var(--director-accent)]/15">
<AssetIcon mediaType={asset.mediaType} />
</span>
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-[var(--director-dock-fg-strong)]">
{asset.title || asset.storageKey}
</span>
<button
ref={closeRef}
type="button"
onClick={onClose}
aria-label="关闭详情"
className="grid size-5 shrink-0 place-items-center rounded-md text-[var(--director-dock-fg)]/55 transition-colors hover:bg-[var(--director-control-hover)] hover:text-[var(--director-dock-fg-strong)]"
>
<X className="size-3.5" />
</button>
</div>
<div className="director-scroll min-h-0 flex-1 overflow-y-auto px-2.5 py-2">
<MediaPreview asset={asset} />
<dl className="mt-2.5 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>
{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-2 line-clamp-2 text-[10px] leading-relaxed text-[var(--director-dock-fg)]/60">{asset.previewText}</p>
) : null}
</div>
<div className="border-t border-[var(--director-sequencer-border)] p-2">
<button
type="button"
disabled={added}
onClick={onAdd}
className={`flex h-7 w-full items-center justify-center gap-1 rounded-md text-[11px] font-medium transition-colors ${
added
? "bg-[var(--director-success-soft)] text-[var(--director-success)]"
: "bg-[var(--director-accent)] text-[var(--director-on-accent)] hover:bg-[var(--director-accent-hover)]"
}`}
>
{added ? <Check className="size-3.5" /> : <Plus className="size-3.5" />}
{added ? "已添加到时间线" : "添加到时间线"}
</button>
</div>
</div>
);
}
@@ -233,10 +307,16 @@ export function EditorAssetIngest() {
const [filter, setFilter] = useState<AssetFilter>("all");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [openGroups, setOpenGroups] = useState<{ uploaded: boolean; project: boolean; canvas: boolean }>({ uploaded: true, project: true, canvas: true });
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);
// 展开浮层指向的素材被移除/刷新消失时自动关闭,避免浮层残留指向已不存在的数据。
useEffect(() => {
if (expandedId && !assets.some((a) => a.id === expandedId)) {
setExpandedId(null);
}
}, [assets, expandedId]);
if (!project) return null;
@@ -346,15 +426,15 @@ export function EditorAssetIngest() {
};
const uploadedAssets = assets.filter((a) => a.source === SOURCE_UPLOADED);
const canvasAssets = assets.filter((a) => a.source === SOURCE_CANVAS);
const projectAssets = assets.filter((a) => a.source !== SOURCE_UPLOADED && a.source !== SOURCE_CANVAS);
const groups: { id: "uploaded" | "project" | "canvas"; label: string; icon: typeof HardDrive; items: ProjectAsset[] }[] = [
// 画布产物已自动同步为普通项目素材:并入「项目素材」,不再按来源单列分组。
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: "canvas", label: "画布素材", icon: Boxes, items: canvasAssets },
{ id: "project", label: "项目素材", icon: Boxes, items: projectAssets },
];
const visibleGroups = groups.filter((g) => filter === "all" || g.id === filter);
const totalCount = assets.length;
const expandedAsset = assets.find((a) => a.id === expandedId) ?? null;
return (
<div
@@ -419,7 +499,8 @@ export function EditorAssetIngest() {
{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">
<div className="relative min-h-0 flex-1">
<div className="director-scroll h-full 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>
@@ -428,7 +509,7 @@ export function EditorAssetIngest() {
) : 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 === "canvas" ? "暂无画布素材,请先在画布工作台生成分镜/预演" : filter === "project" ? "暂无项目素材" : "项目暂无资产"}
{filter === "uploaded" ? "暂无本地上传的媒体" : filter === "project" ? "暂无项目素材" : "项目暂无资产"}
</p>
<p className="max-w-[180px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">点击上方导入媒体,或将文件拖入此区域</p>
</div>
@@ -460,7 +541,6 @@ export function EditorAssetIngest() {
<ul className="flex flex-col">
{group.items.map((asset) => {
const expanded = expandedId === asset.id;
const source = asset.source || "";
return (
<li key={asset.id}>
<button
@@ -478,7 +558,6 @@ export function EditorAssetIngest() {
<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
@@ -489,35 +568,6 @@ export function EditorAssetIngest() {
<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 ? "本地上传" : source === SOURCE_CANVAS ? "画布素材" : "项目素材"}</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>
);
})}
@@ -528,6 +578,15 @@ export function EditorAssetIngest() {
})}
</div>
)}
</div>
{expandedAsset ? (
<AssetDetailSheet
asset={expandedAsset}
added={added === expandedAsset.id}
onClose={() => setExpandedId(null)}
onAdd={() => addToTimeline(expandedAsset)}
/>
) : null}
</div>
</div>
);