feat: standardize media asset tiles

This commit is contained in:
2026-05-18 11:57:46 +08:00
parent 8f917d52b8
commit c7c7301c13
5 changed files with 412 additions and 251 deletions

View File

@@ -37,3 +37,11 @@
- 任何改动只要影响产品理解、节点职责、界面行为、数据模型、API、运行方式或用户操作路径必须在同一次任务里更新 `docs/source-analysis.html` - 任何改动只要影响产品理解、节点职责、界面行为、数据模型、API、运行方式或用户操作路径必须在同一次任务里更新 `docs/source-analysis.html`
- 更新时至少补充“变更记录”,必要时同步更新源码结构地图、界面区域到源码、数据模型、接口地图、节点职责边界 - 更新时至少补充“变更记录”,必要时同步更新源码结构地图、界面区域到源码、数据模型、接口地图、节点职责边界
- 不要把源码解析页接入主应用路由,除非用户明确要求;它默认是项目内独立 HTML 文档 - 不要把源码解析页接入主应用路由,除非用户明确要求;它默认是项目内独立 HTML 文档
## Media Asset UI Contract
- 任何图片 / 视频 / 抽帧 / 产品图 / 生成图 / 首尾帧 / 视频候选缩略图,不允许临时手写一套孤立交互;当前工作台新增媒体展示默认复用 `web/components/media-asset-tile.tsx`
- 所有媒体缩略图默认支持鼠标停留放大预览;预览层必须挂到顶层固定浮层,不能被滚动容器、面板或表格裁切
- 可删除的媒体素材必须显示删除入口;删除按钮、重新生成按钮、状态遮罩和悬停预览交互要在全项目保持一致
- 缩略图尺寸可按区域调整,但图片 / 视频必须可完整查看;需要裁切时必须仍能通过悬停预览看到完整素材
- 新增媒体板块验收时必须检查:悬停放大、删除入口、预览不被遮挡、图片完整性、视频预览、模型 / 状态标注是否与已有板块一致

View File

@@ -79,6 +79,7 @@
- 没有公网地址时,`.project.json.urls` 保持空数组 - 没有公网地址时,`.project.json.urls` 保持空数组
- 任何部署或域名变化,都要先改元数据,再视为任务完成 - 任何部署或域名变化,都要先改元数据,再视为任务完成
- 用户给到源码 / 下载包 / 参考实现时,默认优先按源码实现和复刻,不先自创“类似效果”;如果因安全、依赖、性能或部署限制必须改写,必须先说明差异和原因。 - 用户给到源码 / 下载包 / 参考实现时,默认优先按源码实现和复刻,不先自创“类似效果”;如果因安全、依赖、性能或部署限制必须改写,必须先说明差异和原因。
- 媒体素材交互为项目基底规则任何图片、视频、抽帧、产品图、AI 生成图、首尾帧和视频候选缩略图,默认复用 `web/components/media-asset-tile.tsx`;必须支持鼠标停留顶层放大预览,可删除素材必须有删除按钮,预览不能被面板或滚动容器遮挡。
## 注意事项 ## 注意事项
- 项目内源码解析页:`docs/source-analysis.html` - 项目内源码解析页:`docs/source-analysis.html`

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react" import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom" import { createPortal } from "react-dom"
import { import {
AlertTriangle, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, AlertTriangle, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2,
@@ -49,6 +49,7 @@ import {
videoUrl, videoUrl,
} from "@/lib/api" } from "@/lib/api"
import { type NodeData } from "@/components/nodes" import { type NodeData } from "@/components/nodes"
import { MediaAssetTile } from "@/components/media-asset-tile"
const TARGETS: Array<{ value: FrameExtractTarget; label: string }> = [ const TARGETS: Array<{ value: FrameExtractTarget; label: string }> = [
{ value: "balanced", label: "综合" }, { value: "balanced", label: "综合" },
@@ -942,8 +943,11 @@ function subjectAssetRefsForPlanning(source: { frame: KeyFrame; element: KeyElem
function endpointAssetRef(frame: KeyFrame | null, role: "first_frame" | "last_frame"): ImageRef | null { function endpointAssetRef(frame: KeyFrame | null, role: "first_frame" | "last_frame"): ImageRef | null {
if (!frame) return null if (!frame) return null
const saved = role === "first_frame" ? frame.storyboard?.first_image : frame.storyboard?.last_image const key = role === "first_frame" ? "first_image" : "last_image"
if (saved && saved.kind !== "keyframe") return saved if (frame.storyboard && Object.prototype.hasOwnProperty.call(frame.storyboard, key)) {
const saved = role === "first_frame" ? frame.storyboard.first_image : frame.storyboard.last_image
return saved && saved.kind !== "keyframe" ? saved : null
}
const asset = [...(frame.scene_assets ?? [])].reverse().find((item) => item.asset_role === role) const asset = [...(frame.scene_assets ?? [])].reverse().find((item) => item.asset_role === role)
if (!asset) return null if (!asset) return null
return { return {
@@ -1320,6 +1324,7 @@ export function AdRecreationBoard({
job={job} job={job}
selectedFrames={data.selectedFrames} selectedFrames={data.selectedFrames}
onJobUpdate={data.onJobUpdate} onJobUpdate={data.onJobUpdate}
onDeleteVideo={data.onDeleteVideo}
runtimeModels={runtimeModels} runtimeModels={runtimeModels}
/> />
</div> </div>
@@ -1718,8 +1723,6 @@ function SourceReferenceBuildPanel({
const [subjectBusy, setSubjectBusy] = useState(false) const [subjectBusy, setSubjectBusy] = useState(false)
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null) const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
const [deletingFrame, setDeletingFrame] = useState<number | null>(null) const [deletingFrame, setDeletingFrame] = useState<number | null>(null)
const [framePreview, setFramePreview] = useState<{ index: number; left: number; top: number } | null>(null)
const [subjectAssetPreview, setSubjectAssetPreview] = useState<{ id: string; left: number; top: number } | null>(null)
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human") const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
const [subjectDirection, setSubjectDirection] = useState("") const [subjectDirection, setSubjectDirection] = useState("")
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([]) const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
@@ -1755,8 +1758,6 @@ function SourceReferenceBuildPanel({
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
}) })
}, [actorAssets]) }, [actorAssets])
const previewFrame = framePreview ? frames.find((frame) => frame.index === framePreview.index) ?? null : null
const previewSubjectAsset = subjectAssetPreview ? visibleActorAssets.find((asset) => asset.id === subjectAssetPreview.id) ?? null : null
const referenceCountLabel = selectedReferenceFrames.length const referenceCountLabel = selectedReferenceFrames.length
? `使用已选 ${selectedReferenceFrames.length}` ? `使用已选 ${selectedReferenceFrames.length}`
: frames.length : frames.length
@@ -1894,85 +1895,8 @@ function SourceReferenceBuildPanel({
} }
} }
const updateFramePreviewPosition = (event: ReactMouseEvent<HTMLDivElement>, frameIndex: number) => {
const margin = 16
const previewWidth = Math.min(340, window.innerWidth - margin * 2)
const previewHeight = previewWidth * 16 / 9 + 44
let left = event.clientX + 18
let top = event.clientY + 18
if (left + previewWidth > window.innerWidth - margin) {
left = event.clientX - previewWidth - 18
}
if (top + previewHeight > window.innerHeight - margin) {
top = window.innerHeight - previewHeight - margin
}
setFramePreview({
index: frameIndex,
left: Math.max(margin, left),
top: Math.max(margin, top),
})
}
const updateSubjectAssetPreviewPosition = (event: ReactMouseEvent<HTMLElement>, assetId: string) => {
const margin = 16
const previewWidth = Math.min(420, window.innerWidth - margin * 2)
const previewHeight = Math.min(720, window.innerHeight - margin * 2)
let left = event.clientX + 18
let top = event.clientY + 18
if (left + previewWidth > window.innerWidth - margin) {
left = event.clientX - previewWidth - 18
}
if (top + previewHeight > window.innerHeight - margin) {
top = window.innerHeight - previewHeight - margin
}
setSubjectAssetPreview({
id: assetId,
left: Math.max(margin, left),
top: Math.max(margin, top),
})
}
const framePreviewPortal = framePreview && previewFrame && typeof document !== "undefined"
? createPortal(
<div
className="pointer-events-none fixed z-[9999] w-[min(340px,calc(100vw-32px))] rounded-xl border border-white/15 bg-black/94 p-3 shadow-[0_28px_80px_rgba(0,0,0,0.72)]"
style={{ left: framePreview.left, top: framePreview.top }}
>
<img src={effectiveFrameUrl(job.id, previewFrame)} alt="" className="aspect-[9/16] w-full rounded-lg bg-black object-contain" />
<div className="mt-2 flex items-center justify-between gap-3 text-[11px] text-white/62">
<span> {String(frames.findIndex((frame) => frame.index === previewFrame.index) + 1).padStart(2, "0")}</span>
<span className="font-mono">{previewFrame.timestamp.toFixed(1)}s</span>
</div>
</div>,
document.body,
)
: null
const subjectAssetPreviewPortal = subjectAssetPreview && previewSubjectAsset && typeof document !== "undefined"
? createPortal(
<div
className="pointer-events-none fixed z-[10000] w-[min(420px,calc(100vw-32px))] rounded-xl border border-white/15 bg-black/94 p-3 shadow-[0_28px_80px_rgba(0,0,0,0.72)]"
style={{ left: subjectAssetPreview.left, top: subjectAssetPreview.top }}
>
<div className="flex max-h-[min(70vh,620px)] items-center justify-center rounded-lg bg-white p-2">
<img
src={subjectAssetUrl(job, previewSubjectAsset)}
alt=""
className="max-h-[min(66vh,580px)] w-full object-contain"
/>
</div>
<div className="mt-2 flex items-center justify-between gap-3 text-[11px] text-white/62">
<span className="truncate">{previewSubjectAsset.label || previewSubjectAsset.view || "主体视图预览"}</span>
<span className="shrink-0 font-mono">{previewSubjectAsset.width}x{previewSubjectAsset.height}</span>
</div>
</div>,
document.body,
)
: null
return ( return (
<div className="min-w-0"> <div className="min-w-0">
{framePreviewPortal}
{subjectAssetPreviewPortal}
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="关键帧 / 相似主体" /> <SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="关键帧 / 相似主体" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -2001,43 +1925,23 @@ function SourceReferenceBuildPanel({
{frames.map((frame, index) => { {frames.map((frame, index) => {
const selected = selectedFrames.has(frame.index) const selected = selectedFrames.has(frame.index)
return ( return (
<div <MediaAssetTile
key={frame.index} key={frame.index}
onMouseEnter={(event) => updateFramePreviewPosition(event, frame.index)} src={effectiveFrameUrl(job.id, frame)}
onMouseMove={(event) => updateFramePreviewPosition(event, frame.index)} alt={`关键帧 ${index + 1}`}
onMouseLeave={() => setFramePreview(null)} label={`参考帧 ${String(index + 1).padStart(2, "0")}`}
className={`group relative aspect-[9/16] overflow-hidden rounded border bg-black transition ${ meta={`${frame.timestamp.toFixed(1)}s`}
selected ? "border-emerald-300/70" : "border-white/10 hover:border-cyan-300/40" className="aspect-[9/16]"
}`} objectFit="contain"
selected={selected}
title={`关键帧 ${index + 1} · ${frame.timestamp.toFixed(1)}s`} title={`关键帧 ${index + 1} · ${frame.timestamp.toFixed(1)}s`}
> onClick={() => onToggleFrame(frame.index)}
<button topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
type="button" topRight={<span className="rounded-full bg-black/72 p-0.5">{selected ? <Check className="h-3 w-3 text-emerald-200" /> : <Circle className="h-3 w-3 text-white/50" />}</span>}
onClick={() => onToggleFrame(frame.index)} onDelete={onDeleteFrame ? () => void deleteReferenceFrame(frame.index) : undefined}
className="absolute inset-0 cursor-pointer overflow-hidden focus:outline-none focus:ring-1 focus:ring-cyan-200/70" deleting={deletingFrame === frame.index}
> deleteLabel={`删除关键帧 ${index + 1}`}
<img src={effectiveFrameUrl(job.id, frame)} alt="" className="h-full w-full object-contain" /> />
</button>
<span className="absolute left-1 top-1 rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>
<span className="absolute right-1 top-1 rounded-full bg-black/72 p-0.5">
{selected ? <Check className="h-3 w-3 text-emerald-200" /> : <Circle className="h-3 w-3 text-white/50" />}
</span>
{onDeleteFrame && (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
void deleteReferenceFrame(frame.index)
}}
disabled={deletingFrame === frame.index}
className="absolute bottom-1 right-1 inline-flex h-6 w-6 items-center justify-center rounded-full border border-rose-200/25 bg-black/78 text-rose-100 opacity-80 transition hover:border-rose-200/55 hover:bg-rose-500/25 hover:opacity-100 focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-rose-100/70 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={`删除关键帧 ${index + 1}`}
title="删除这张关键帧"
>
{deletingFrame === frame.index ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
</button>
)}
</div>
) )
})} })}
{!frames.length && ( {!frames.length && (
@@ -2151,53 +2055,30 @@ function SourceReferenceBuildPanel({
{visibleActorAssets.map((asset) => { {visibleActorAssets.map((asset) => {
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : "" const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
return ( return (
<div <MediaAssetTile
key={asset.id} key={asset.id}
onMouseEnter={(event) => updateSubjectAssetPreviewPosition(event, asset.id)} src={subjectAssetUrl(job, asset)}
onMouseMove={(event) => updateSubjectAssetPreviewPosition(event, asset.id)}
onMouseLeave={() => setSubjectAssetPreview(null)}
className="group relative aspect-[9/16] w-12 overflow-hidden rounded border border-white/10 bg-white transition hover:border-cyan-200/70 2xl:w-14"
title={asset.label || asset.view}
>
<a
href={subjectAssetUrl(job, asset)} href={subjectAssetUrl(job, asset)}
target="_blank" alt={asset.label || asset.view}
rel="noreferrer" label={asset.label || asset.view || "主体视图预览"}
className="absolute inset-0" meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
> className="aspect-[9/16] w-12 bg-white 2xl:w-14"
<img src={subjectAssetUrl(job, asset)} alt={asset.label || asset.view} className="h-full w-full object-contain" /> objectFit="contain"
</a> title={asset.label || asset.view}
<div className="absolute right-1 top-1 flex flex-col gap-0.5 opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100"> actions={[{
<button key: "regen",
type="button" label: "重新生成这一张",
onClick={(event) => { icon: <RefreshCw className="h-3 w-3" />,
event.preventDefault() tone: "cyan",
event.stopPropagation() busy: busyMode === "regen",
void regenerateSubjectAsset(asset) disabled: !!subjectAssetBusy,
}} onClick: () => void regenerateSubjectAsset(asset),
disabled={!!subjectAssetBusy} }]}
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-cyan-100/35 bg-black/78 text-cyan-100 transition hover:border-cyan-100/70 hover:bg-cyan-500/25 focus:outline-none focus:ring-1 focus:ring-cyan-100/70 disabled:cursor-not-allowed disabled:opacity-60" onDelete={() => void deleteActorAsset(asset)}
aria-label={`重新生成${asset.label || asset.view}`} deleting={busyMode === "delete"}
title="重新生成这一张" deleteDisabled={!!subjectAssetBusy}
> deleteLabel="删除这一张"
{busyMode === "regen" ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RefreshCw className="h-2.5 w-2.5" />} />
</button>
<button
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void deleteActorAsset(asset)
}}
disabled={!!subjectAssetBusy}
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-rose-100/35 bg-black/78 text-rose-100 transition hover:border-rose-100/70 hover:bg-rose-500/25 focus:outline-none focus:ring-1 focus:ring-rose-100/70 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={`删除${asset.label || asset.view}`}
title="删除这一张"
>
{busyMode === "delete" ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <Trash2 className="h-2.5 w-2.5" />}
</button>
</div>
</div>
) )
})} })}
</div> </div>
@@ -2216,11 +2097,13 @@ function AudioStoryboardPlanPanel({
job, job,
selectedFrames, selectedFrames,
onJobUpdate, onJobUpdate,
onDeleteVideo,
runtimeModels, runtimeModels,
}: { }: {
job: Job | null job: Job | null
selectedFrames: Set<number> selectedFrames: Set<number>
onJobUpdate?: (job: Job) => void onJobUpdate?: (job: Job) => void
onDeleteVideo?: (videoId: string) => void
runtimeModels?: RuntimeModels runtimeModels?: RuntimeModels
}) { }) {
const [storyboardSaveBusyRow, setStoryboardSaveBusyRow] = useState<number | null>(null) const [storyboardSaveBusyRow, setStoryboardSaveBusyRow] = useState<number | null>(null)
@@ -2579,6 +2462,26 @@ function AudioStoryboardPlanPanel({
} }
} }
const clearEndpointFrameForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, role: "first_frame" | "last_frame") => {
if (!job || !frame) return
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row) }
const busyKey = `${row.index}:clear_${role}`
setEndpointFrameBusy(busyKey)
try {
const scene = buildStoryboardSceneFromAudioRow(plannedRow, frame, productItems, subjectRefs, {
firstImage: role === "first_frame" ? null : endpointAssetRef(frame, "first_frame"),
lastImage: role === "last_frame" ? null : endpointAssetRef(frame, "last_frame"),
})
const updated = await updateStoryboard(job.id, frame.index, scene)
onJobUpdate?.(updated)
toast.success(`${role === "first_frame" ? "首帧" : "尾帧"}已从本条规划移除`)
} catch (e) {
toast.error("移除首尾帧失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setEndpointFrameBusy(null)
}
}
const saveSingleRowStoryboardDraft = async (row: AudioStoryboardRow, frame: KeyFrame | null) => { const saveSingleRowStoryboardDraft = async (row: AudioStoryboardRow, frame: KeyFrame | null) => {
if (!job || !frame) return if (!job || !frame) return
setStoryboardSaveBusyRow(row.index) setStoryboardSaveBusyRow(row.index)
@@ -2868,16 +2771,20 @@ function AudioStoryboardPlanPanel({
frame={referenceFrame} frame={referenceFrame}
role="first_frame" role="first_frame"
busy={endpointFrameBusy === `${row.index}:first_frame`} busy={endpointFrameBusy === `${row.index}:first_frame`}
deleting={endpointFrameBusy === `${row.index}:clear_first_frame`}
disabled={!referenceFrame || (plannedRow.needsSubject && !subjectRefs.length) || (plannedRow.needsProduct && !productItems.length)} disabled={!referenceFrame || (plannedRow.needsSubject && !subjectRefs.length) || (plannedRow.needsProduct && !productItems.length)}
onGenerate={() => void generateEndpointFrameForRow(plannedRow, referenceFrame, "first_frame")} onGenerate={() => void generateEndpointFrameForRow(plannedRow, referenceFrame, "first_frame")}
onDelete={() => void clearEndpointFrameForRow(plannedRow, referenceFrame, "first_frame")}
/> />
<EndpointFrameSlot <EndpointFrameSlot
job={job} job={job}
frame={referenceFrame} frame={referenceFrame}
role="last_frame" role="last_frame"
busy={endpointFrameBusy === `${row.index}:last_frame`} busy={endpointFrameBusy === `${row.index}:last_frame`}
deleting={endpointFrameBusy === `${row.index}:clear_last_frame`}
disabled={!referenceFrame || (plannedRow.needsSubject && !subjectRefs.length) || (plannedRow.needsProduct && !productItems.length)} disabled={!referenceFrame || (plannedRow.needsSubject && !subjectRefs.length) || (plannedRow.needsProduct && !productItems.length)}
onGenerate={() => void generateEndpointFrameForRow(plannedRow, referenceFrame, "last_frame")} onGenerate={() => void generateEndpointFrameForRow(plannedRow, referenceFrame, "last_frame")}
onDelete={() => void clearEndpointFrameForRow(plannedRow, referenceFrame, "last_frame")}
/> />
</div> </div>
<div className="flex items-center justify-between gap-2 text-[10px] text-white/34"> <div className="flex items-center justify-between gap-2 text-[10px] text-white/34">
@@ -2898,6 +2805,7 @@ function AudioStoryboardPlanPanel({
job={job} job={job}
videos={rowVideos} videos={rowVideos}
enabled={!!endpointAssetRef(referenceFrame, "first_frame") && !!endpointAssetRef(referenceFrame, "last_frame")} enabled={!!endpointAssetRef(referenceFrame, "first_frame") && !!endpointAssetRef(referenceFrame, "last_frame")}
onDeleteVideo={onDeleteVideo}
/> />
<div className="mt-1 truncate text-[10px] text-white/34" title="视频生成已暂停,首尾帧确认后再开放单条提交"> <div className="mt-1 truncate text-[10px] text-white/34" title="视频生成已暂停,首尾帧确认后再开放单条提交">
{endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame") {endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame")
@@ -2949,59 +2857,30 @@ function ProductReferenceCard({
const assetWarnings = item.assetMeta?.warnings ?? [] const assetWarnings = item.assetMeta?.warnings ?? []
const assetActions = item.assetMeta?.actions ?? [] const assetActions = item.assetMeta?.actions ?? []
const orientationText = formatProductOrientation(item.orientation) const orientationText = formatProductOrientation(item.orientation)
const [previewPos, setPreviewPos] = useState<{ left: number; top: number } | null>(null) const previewDetail = (
<>
function updatePreviewPosition(event: ReactMouseEvent<HTMLDivElement>) { {productViewLabel(item.view)} · {productBackgroundLabel(item.background)} · {tagLabels.join(" / ") || "用途待标注"}
const margin = 16 <br />
const previewWidth = Math.min(380, window.innerWidth - margin * 2) {item.note || "无备注"}
const previewHeight = previewWidth + 118 {orientationText ? <><br />{orientationText}</> : null}
let left = event.clientX + 18 {item.landmarks.length ? <><br />{item.landmarks.join(" / ")}</> : null}
let top = event.clientY + 18 {item.risk ? <><br />{item.risk}</> : null}
if (left + previewWidth > window.innerWidth - margin) { {assetWarnings.length ? <><br />{assetWarnings.join("")}</> : null}
left = event.clientX - previewWidth - 18 </>
} )
if (top + previewHeight > window.innerHeight - margin) {
top = window.innerHeight - previewHeight - margin
}
setPreviewPos({
left: Math.max(margin, left),
top: Math.max(margin, top),
})
}
const preview = previewPos && typeof document !== "undefined"
? createPortal(
<div
className="pointer-events-none fixed z-[9999] w-[min(380px,calc(100vw-32px))] rounded-xl border border-white/15 bg-black/94 p-3 shadow-[0_28px_80px_rgba(0,0,0,0.72)]"
style={{ left: previewPos.left, top: previewPos.top }}
>
<img src={src} alt="" className="aspect-square w-full rounded-lg bg-white object-contain" />
<div className="mt-2 text-[11px] leading-snug text-white/68">
{productViewLabel(item.view)} · {productBackgroundLabel(item.background)} · {tagLabels.join(" / ")}
<br />
{item.note}
{orientationText ? <><br />{orientationText}</> : null}
{item.landmarks.length ? <><br />{item.landmarks.join(" / ")}</> : null}
{item.risk ? <><br />{item.risk}</> : null}
{assetWarnings.length ? <><br />{assetWarnings.join("")}</> : null}
</div>
</div>,
document.body,
)
: null
return ( return (
<div className="grid min-w-0 grid-cols-[74px_minmax(0,1fr)_28px] gap-2 rounded-md border border-white/10 bg-black/26 p-2"> <div className="grid min-w-0 grid-cols-[74px_minmax(0,1fr)_28px] gap-2 rounded-md border border-white/10 bg-black/26 p-2">
<div <MediaAssetTile
className="relative h-[74px] w-[74px] rounded-md border border-white/10 bg-white" src={src}
onMouseEnter={updatePreviewPosition} alt={productViewLabel(item.view)}
onMouseMove={updatePreviewPosition} label={productViewLabel(item.view)}
onMouseLeave={() => setPreviewPos(null)} meta={formatProductAssetSize(item.assetMeta)}
> previewDetail={previewDetail}
<img src={src} alt={productViewLabel(item.view)} className="h-full w-full rounded-md object-contain" /> className="h-[74px] w-[74px] bg-white"
{preview} objectFit="contain"
<span className="absolute left-1 top-1 rounded bg-black/70 px-1 text-[9px] text-white/75">{item.source === "ai" ? "AI" : "图"}</span> topLeft={<span className="rounded bg-black/70 px-1 text-[9px] text-white/75">{item.source === "ai" ? "AI" : "图"}</span>}
</div> />
<div className="min-w-0"> <div className="min-w-0">
<div className="flex h-7 w-full items-center justify-between gap-2 rounded-md border border-white/10 bg-black/55 px-2 text-[11px] text-white/78"> <div className="flex h-7 w-full items-center justify-between gap-2 rounded-md border border-white/10 bg-black/55 px-2 text-[11px] text-white/78">
<span className="truncate font-semibold">{productViewLabel(item.view)}</span> <span className="truncate font-semibold">{productViewLabel(item.view)}</span>
@@ -3085,14 +2964,30 @@ function StoryboardPlanCell({ label, children, className = "" }: { label: string
) )
} }
function StoryboardVideoSlots({ job, videos, enabled }: { job: Job; videos: GeneratedVideo[]; enabled: boolean }) { function StoryboardVideoSlots({
job,
videos,
enabled,
onDeleteVideo,
}: {
job: Job
videos: GeneratedVideo[]
enabled: boolean
onDeleteVideo?: (videoId: string) => void
}) {
const visible = videos.slice(0, 6) const visible = videos.slice(0, 6)
const emptyCount = Math.max(0, 6 - visible.length) const emptyCount = Math.max(0, 6 - visible.length)
return ( return (
<div> <div>
<div className="grid grid-cols-6 gap-1.5"> <div className="grid grid-cols-6 gap-1.5">
{visible.map((video) => ( {visible.map((video) => (
<StoryboardVideoPreview key={video.id} job={job} video={video} className="aspect-[9/16] min-h-[86px] w-full" /> <StoryboardVideoPreview
key={video.id}
job={job}
video={video}
className="aspect-[9/16] min-h-[86px] w-full"
onDelete={onDeleteVideo ? () => onDeleteVideo(video.id) : undefined}
/>
))} ))}
{Array.from({ length: emptyCount }).map((_, index) => ( {Array.from({ length: emptyCount }).map((_, index) => (
<div key={`empty-video-${index}`} className="flex aspect-[9/16] min-h-[86px] min-w-0 items-center justify-center rounded border border-dashed border-white/12 bg-black/25 px-1 text-center text-[9.5px] leading-tight text-white/26"> <div key={`empty-video-${index}`} className="flex aspect-[9/16] min-h-[86px] min-w-0 items-center justify-center rounded border border-dashed border-white/12 bg-black/25 px-1 text-center text-[9.5px] leading-tight text-white/26">
@@ -3112,35 +3007,38 @@ function EndpointFrameSlot({
frame, frame,
role, role,
busy, busy,
deleting,
disabled, disabled,
onGenerate, onGenerate,
onDelete,
}: { }: {
job: Job job: Job
frame: KeyFrame | null frame: KeyFrame | null
role: "first_frame" | "last_frame" role: "first_frame" | "last_frame"
busy: boolean busy: boolean
deleting?: boolean
disabled: boolean disabled: boolean
onGenerate: () => void onGenerate: () => void
onDelete?: () => void
}) { }) {
const ref = endpointAssetRef(frame, role) const ref = endpointAssetRef(frame, role)
const src = ref ? resolveImageRefUrl(job.id, ref) : "" const src = ref ? resolveImageRefUrl(job.id, ref) : ""
const label = role === "first_frame" ? "首帧" : "尾帧" const label = role === "first_frame" ? "首帧" : "尾帧"
return ( return (
<div className="overflow-hidden rounded border border-white/10 bg-black/32"> <div className="overflow-hidden rounded border border-white/10 bg-black/32">
<div className="relative flex aspect-[9/16] min-h-[112px] items-center justify-center bg-black"> <MediaAssetTile
{src ? ( src={src}
<a href={src} target="_blank" rel="noreferrer" className="group h-full w-full"> href={src || undefined}
<img src={src} alt={`${label}资产`} className="h-full w-full object-contain transition group-hover:scale-[1.02]" /> alt={`${label}资产`}
</a> label={`${label}资产`}
) : ( className="aspect-[9/16] min-h-[112px] border-0"
<div className="px-2 text-center text-[10px] leading-snug text-white/28">{label}</div> objectFit="contain"
)} busy={busy}
{busy && ( emptyText={`先生成${label}`}
<div className="absolute inset-0 flex items-center justify-center bg-black/65"> onDelete={src && onDelete ? onDelete : undefined}
<Loader2 className="h-4 w-4 animate-spin text-white/80" /> deleting={deleting}
</div> deleteLabel={`移除${label}`}
)} />
</div>
<button <button
type="button" type="button"
onClick={onGenerate} onClick={onGenerate}
@@ -3154,30 +3052,37 @@ function EndpointFrameSlot({
) )
} }
function StoryboardVideoPreview({ job, video, className = "h-20 w-12" }: { job: Job; video: GeneratedVideo; className?: string }) { function StoryboardVideoPreview({
job,
video,
className = "h-20 w-12",
onDelete,
}: {
job: Job
video: GeneratedVideo
className?: string
onDelete?: () => void
}) {
const src = videoSrc(video) const src = videoSrc(video)
const poster = videoPoster(job, video) const poster = videoPoster(job, video)
const running = video.status === "queued" || video.status === "in_progress" const running = video.status === "queued" || video.status === "in_progress"
return ( return (
<a <MediaAssetTile
kind="video"
src={src && video.status === "completed" ? src : undefined}
poster={poster}
href={src || undefined} href={src || undefined}
target={src ? "_blank" : undefined} alt={`片段 ${shortId(video.id)}`}
rel={src ? "noreferrer" : undefined} label={`${shortId(video.id)} · ${video.model}`}
className={`group relative shrink-0 overflow-hidden rounded border border-white/10 bg-black/45 ${className}`} meta={video.status}
className={`shrink-0 bg-black/45 ${className}`}
objectFit="cover"
title={`${video.model} · ${video.status}`} title={`${video.model} · ${video.status}`}
> bottom={<span className="block truncate rounded bg-black/70 px-1 py-0.5 text-center font-mono text-[9px] text-white/62">{running ? "生成中" : video.status === "failed" ? "失败" : shortId(video.id)}</span>}
{src && video.status === "completed" ? ( topRight={running ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100" /> : undefined}
<video src={src} poster={poster} muted playsInline className="h-full w-full object-cover" /> onDelete={onDelete}
) : poster ? ( deleteLabel="删除这个视频候选"
<img src={poster} alt={`片段 ${shortId(video.id)}`} className="h-full w-full object-cover opacity-80" /> />
) : (
<div className="flex h-full w-full items-center justify-center text-white/30"><Film className="h-4 w-4" /></div>
)}
<span className="absolute bottom-1 left-1 right-1 truncate rounded bg-black/70 px-1 py-0.5 text-center font-mono text-[9px] text-white/62">
{running ? "生成中" : video.status === "failed" ? "失败" : shortId(video.id)}
</span>
{running && <Loader2 className="absolute right-1 top-1 h-3 w-3 animate-spin text-cyan-100" />}
</a>
) )
} }

View File

@@ -0,0 +1,234 @@
"use client"
import { type MouseEvent as ReactMouseEvent, type ReactNode, useState } from "react"
import { createPortal } from "react-dom"
import { Film, Loader2, Trash2 } from "lucide-react"
type MediaAssetAction = {
key: string
label: string
icon: ReactNode
onClick: () => void
disabled?: boolean
busy?: boolean
tone?: "neutral" | "cyan" | "rose"
}
type MediaAssetTileProps = {
kind?: "image" | "video"
src?: string
poster?: string
href?: string
alt?: string
title?: string
label?: ReactNode
meta?: ReactNode
previewDetail?: ReactNode
className?: string
mediaClassName?: string
objectFit?: "contain" | "cover"
previewObjectFit?: "contain" | "cover"
previewClassName?: string
selected?: boolean
disabled?: boolean
busy?: boolean
topLeft?: ReactNode
topRight?: ReactNode
bottom?: ReactNode
emptyText?: string
onClick?: () => void
onDelete?: () => void
deleteLabel?: string
deleting?: boolean
deleteDisabled?: boolean
actions?: MediaAssetAction[]
disablePreview?: boolean
}
const actionToneClass: Record<NonNullable<MediaAssetAction["tone"]>, string> = {
neutral: "border-white/18 bg-black/78 text-white/82 hover:border-white/42 hover:bg-white/12",
cyan: "border-cyan-100/35 bg-black/78 text-cyan-100 hover:border-cyan-100/70 hover:bg-cyan-500/25",
rose: "border-rose-100/35 bg-black/78 text-rose-100 hover:border-rose-100/70 hover:bg-rose-500/25",
}
function mediaObjectClass(fit: "contain" | "cover") {
return fit === "cover" ? "object-cover" : "object-contain"
}
function previewPosition(event: ReactMouseEvent<HTMLElement>) {
const margin = 16
const previewWidth = Math.min(520, window.innerWidth - margin * 2)
const previewHeight = Math.min(760, window.innerHeight - margin * 2)
let left = event.clientX + 18
let top = event.clientY + 18
if (left + previewWidth > window.innerWidth - margin) left = event.clientX - previewWidth - 18
if (top + previewHeight > window.innerHeight - margin) top = window.innerHeight - previewHeight - margin
return { left: Math.max(margin, left), top: Math.max(margin, top) }
}
export function MediaAssetTile({
kind = "image",
src,
poster,
href,
alt = "",
title,
label,
meta,
previewDetail,
className = "",
mediaClassName = "",
objectFit = "contain",
previewObjectFit,
previewClassName = "",
selected = false,
disabled = false,
busy = false,
topLeft,
topRight,
bottom,
emptyText,
onClick,
onDelete,
deleteLabel = "删除素材",
deleting = false,
deleteDisabled = false,
actions = [],
disablePreview = false,
}: MediaAssetTileProps) {
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
const mediaSrc = src || poster || ""
const canPreview = !!mediaSrc && !disablePreview
const fit = mediaObjectClass(objectFit)
const previewFit = mediaObjectClass(previewObjectFit ?? objectFit)
const updatePreview = (event: ReactMouseEvent<HTMLElement>) => {
if (!canPreview) return
setPosition(previewPosition(event))
}
const media = kind === "video" && src ? (
<video src={src} poster={poster} muted playsInline preload="metadata" className={`h-full w-full ${fit} ${mediaClassName}`} />
) : mediaSrc ? (
<img src={mediaSrc} alt={alt} className={`h-full w-full ${fit} ${mediaClassName}`} />
) : (
<div className="flex h-full w-full items-center justify-center text-white/28">
<Film className="h-4 w-4" />
{emptyText ? <span className="ml-1 text-[10px]">{emptyText}</span> : null}
</div>
)
const interactiveClass = disabled ? "cursor-not-allowed opacity-55" : onClick || href ? "cursor-pointer" : ""
const bodyClass = `absolute inset-0 block h-full w-full overflow-hidden focus:outline-none focus:ring-1 focus:ring-cyan-200/70 ${interactiveClass}`
const body = href ? (
<a href={href} target="_blank" rel="noreferrer" className={bodyClass} onClick={(event) => { if (disabled) event.preventDefault() }}>
{media}
</a>
) : onClick ? (
<button type="button" disabled={disabled} onClick={onClick} className={bodyClass}>
{media}
</button>
) : (
<div className={bodyClass}>{media}</div>
)
const preview = position && canPreview && typeof document !== "undefined"
? createPortal(
<div
className={`pointer-events-none fixed z-[10000] w-[min(520px,calc(100vw-32px))] rounded-xl border border-white/15 bg-black/94 p-3 shadow-[0_28px_80px_rgba(0,0,0,0.72)] ${previewClassName}`}
style={{ left: position.left, top: position.top }}
>
<div className="flex max-h-[min(76vh,720px)] items-center justify-center overflow-hidden rounded-lg bg-black">
{kind === "video" && src ? (
<video
src={src}
poster={poster}
muted
loop
playsInline
autoPlay
preload="auto"
className={`max-h-[min(74vh,700px)] max-w-full ${previewFit}`}
/>
) : (
<img
src={mediaSrc}
alt=""
className={`max-h-[min(74vh,700px)] max-w-full ${previewFit}`}
/>
)}
</div>
{(label || meta || previewDetail) && (
<div className="mt-2 text-[11px] leading-snug text-white/66">
{(label || meta) && (
<div className="flex items-center justify-between gap-3">
<span className="min-w-0 truncate">{label}</span>
{meta ? <span className="shrink-0 font-mono text-white/44">{meta}</span> : null}
</div>
)}
{previewDetail ? <div className="mt-1 text-white/58">{previewDetail}</div> : null}
</div>
)}
</div>,
document.body,
)
: null
return (
<div
className={`group relative overflow-hidden rounded border bg-black transition ${selected ? "border-emerald-300/70" : "border-white/10 hover:border-cyan-300/45"} ${className}`}
title={title}
onMouseEnter={updatePreview}
onMouseMove={updatePreview}
onMouseLeave={() => setPosition(null)}
>
{body}
{preview}
{topLeft ? <div className="pointer-events-none absolute left-1 top-1 z-10">{topLeft}</div> : null}
{topRight ? <div className="pointer-events-none absolute right-1 top-1 z-10">{topRight}</div> : null}
{bottom ? <div className="pointer-events-none absolute bottom-1 left-1 right-1 z-10">{bottom}</div> : null}
{(actions.length || onDelete) ? (
<div className="absolute right-1 top-1 z-20 flex flex-col gap-0.5 opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100">
{actions.map((action) => (
<button
key={action.key}
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (!action.disabled && !action.busy) action.onClick()
}}
disabled={action.disabled || action.busy}
className={`inline-flex h-5 w-5 items-center justify-center rounded-full border transition focus:outline-none focus:ring-1 focus:ring-cyan-100/70 disabled:cursor-not-allowed disabled:opacity-60 ${actionToneClass[action.tone ?? "neutral"]}`}
aria-label={action.label}
title={action.label}
>
{action.busy ? <Loader2 className="h-3 w-3 animate-spin" /> : action.icon}
</button>
))}
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (!deleteDisabled && !deleting) onDelete()
}}
disabled={deleteDisabled || deleting}
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-rose-100/35 bg-black/78 text-rose-100 transition hover:border-rose-100/70 hover:bg-rose-500/25 focus:outline-none focus:ring-1 focus:ring-rose-100/70 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={deleteLabel}
title={deleteLabel}
>
{deleting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
</button>
) : null}
</div>
) : null}
{busy ? (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/65">
<Loader2 className="h-4 w-4 animate-spin text-white/80" />
</div>
) : null}
</div>
)
}