diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index dda418e09..da764d171 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -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 直接放弃, // 避免重复转码同一份文件。 diff --git a/backend/internal/service/video_transcode.go b/backend/internal/service/video_transcode.go index 989d4f8a9..523ec4406 100644 --- a/backend/internal/service/video_transcode.go +++ b/backend/internal/service/video_transcode.go @@ -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]) + } + } } diff --git a/backend/internal/service/video_transcode_test.go b/backend/internal/service/video_transcode_test.go index 38581eb33..59dfe14a8 100644 --- a/backend/internal/service/video_transcode_test.go +++ b/backend/internal/service/video_transcode_test.go @@ -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) + } +} diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 2ac894622..54097ace6 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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/分镜/导出成片)的项目:素材自动并入"项目素材"组展示(不再单列"画布素材"分组/页签/来源徽章),再次进入/刷新不产生重复素材。 - [ ] 项目素材库页与剪辑器素材面板对本轮新同步的画布素材均可见;下载/删除画布素材不影响对应画布产物资源。 ## 画布默认框选与移动期性能优化 diff --git a/web/src/lib/plugins/builtin/editor/editor-asset-ingest.tsx b/web/src/lib/plugins/builtin/editor/editor-asset-ingest.tsx index d845f415b..cb3e42e86 100644 --- a/web/src/lib/plugins/builtin/editor/editor-asset-ingest.tsx +++ b/web/src/lib/plugins/builtin/editor/editor-asset-ingest.tsx @@ -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 }) { ); } - -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(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 ( - - {uploaded ? "本地上传" : canvas ? "画布素材" : "项目素材"} - +
+ + + + + {asset.title || asset.storageKey} + + +
+
+ +
+
媒体类型
+
{asset.mediaType}
+
分类
+
{asset.category}
+ {asset.durationMs ? ( + <> +
时长
+
{formatDurationMs(asset.durationMs)}
+ + ) : null} +
+ {asset.previewText ? ( +

{asset.previewText}

+ ) : null} +
+
+ +
+ ); } @@ -233,10 +307,16 @@ export function EditorAssetIngest() { const [filter, setFilter] = useState("all"); const [expandedId, setExpandedId] = useState(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(null); const lastAddKey = useRef(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 (
{importError}

: null} {importNote ?

{importNote}

: null} -
+
+
{assets.length === 0 ? (

项目暂无资产

@@ -428,7 +509,7 @@ export function EditorAssetIngest() { ) : visibleGroups.every((g) => g.items.length === 0) ? (

- {filter === "uploaded" ? "暂无本地上传的媒体" : filter === "canvas" ? "暂无画布素材,请先在画布工作台生成分镜/预演" : filter === "project" ? "暂无项目素材" : "项目暂无资产"} + {filter === "uploaded" ? "暂无本地上传的媒体" : filter === "project" ? "暂无项目素材" : "项目暂无资产"}

点击上方导入媒体,或将文件拖入此区域

@@ -460,7 +541,6 @@ export function EditorAssetIngest() {
    {group.items.map((asset) => { const expanded = expandedId === asset.id; - const source = asset.source || ""; return (
  • - {expanded ? ( -
    - - -
    -
    媒体类型
    -
    {asset.mediaType}
    -
    分类
    -
    {asset.category}
    -
    来源
    -
    {source === SOURCE_UPLOADED ? "本地上传" : source === SOURCE_CANVAS ? "画布素材" : "项目素材"}
    - {asset.durationMs ? ( - <> -
    时长
    -
    {formatDurationMs(asset.durationMs)}
    - - ) : null} -
    - {asset.previewText ?

    {asset.previewText}

    : null} - -
    - ) : null}
  • ); })} @@ -528,6 +578,15 @@ export function EditorAssetIngest() { })}
)} +
+ {expandedAsset ? ( + setExpandedId(null)} + onAdd={() => addToTimeline(expandedAsset)} + /> + ) : null}
);