feat: standardize media asset tiles
This commit is contained in:
@@ -37,3 +37,11 @@
|
||||
- 任何改动只要影响产品理解、节点职责、界面行为、数据模型、API、运行方式或用户操作路径,必须在同一次任务里更新 `docs/source-analysis.html`
|
||||
- 更新时至少补充“变更记录”,必要时同步更新源码结构地图、界面区域到源码、数据模型、接口地图、节点职责边界
|
||||
- 不要把源码解析页接入主应用路由,除非用户明确要求;它默认是项目内独立 HTML 文档
|
||||
|
||||
## Media Asset UI Contract
|
||||
|
||||
- 任何图片 / 视频 / 抽帧 / 产品图 / 生成图 / 首尾帧 / 视频候选缩略图,不允许临时手写一套孤立交互;当前工作台新增媒体展示默认复用 `web/components/media-asset-tile.tsx`
|
||||
- 所有媒体缩略图默认支持鼠标停留放大预览;预览层必须挂到顶层固定浮层,不能被滚动容器、面板或表格裁切
|
||||
- 可删除的媒体素材必须显示删除入口;删除按钮、重新生成按钮、状态遮罩和悬停预览交互要在全项目保持一致
|
||||
- 缩略图尺寸可按区域调整,但图片 / 视频必须可完整查看;需要裁切时必须仍能通过悬停预览看到完整素材
|
||||
- 新增媒体板块验收时必须检查:悬停放大、删除入口、预览不被遮挡、图片完整性、视频预览、模型 / 状态标注是否与已有板块一致
|
||||
|
||||
1
RULES.md
1
RULES.md
@@ -79,6 +79,7 @@
|
||||
- 没有公网地址时,`.project.json.urls` 保持空数组
|
||||
- 任何部署或域名变化,都要先改元数据,再视为任务完成
|
||||
- 用户给到源码 / 下载包 / 参考实现时,默认优先按源码实现和复刻,不先自创“类似效果”;如果因安全、依赖、性能或部署限制必须改写,必须先说明差异和原因。
|
||||
- 媒体素材交互为项目基底规则:任何图片、视频、抽帧、产品图、AI 生成图、首尾帧和视频候选缩略图,默认复用 `web/components/media-asset-tile.tsx`;必须支持鼠标停留顶层放大预览,可删除素材必须有删除按钮,预览不能被面板或滚动容器遮挡。
|
||||
|
||||
## 注意事项
|
||||
- 项目内源码解析页:`docs/source-analysis.html`
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
"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 {
|
||||
AlertTriangle, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2,
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
videoUrl,
|
||||
} from "@/lib/api"
|
||||
import { type NodeData } from "@/components/nodes"
|
||||
import { MediaAssetTile } from "@/components/media-asset-tile"
|
||||
|
||||
const TARGETS: Array<{ value: FrameExtractTarget; label: string }> = [
|
||||
{ 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 {
|
||||
if (!frame) return null
|
||||
const saved = role === "first_frame" ? frame.storyboard?.first_image : frame.storyboard?.last_image
|
||||
if (saved && saved.kind !== "keyframe") return saved
|
||||
const key = role === "first_frame" ? "first_image" : "last_image"
|
||||
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)
|
||||
if (!asset) return null
|
||||
return {
|
||||
@@ -1320,6 +1324,7 @@ export function AdRecreationBoard({
|
||||
job={job}
|
||||
selectedFrames={data.selectedFrames}
|
||||
onJobUpdate={data.onJobUpdate}
|
||||
onDeleteVideo={data.onDeleteVideo}
|
||||
runtimeModels={runtimeModels}
|
||||
/>
|
||||
</div>
|
||||
@@ -1718,8 +1723,6 @@ function SourceReferenceBuildPanel({
|
||||
const [subjectBusy, setSubjectBusy] = useState(false)
|
||||
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | 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 [subjectDirection, setSubjectDirection] = useState("")
|
||||
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
|
||||
@@ -1755,8 +1758,6 @@ function SourceReferenceBuildPanel({
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
|
||||
})
|
||||
}, [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
|
||||
? `使用已选 ${selectedReferenceFrames.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 (
|
||||
<div className="min-w-0">
|
||||
{framePreviewPortal}
|
||||
{subjectAssetPreviewPortal}
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="关键帧 / 相似主体" />
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -2001,43 +1925,23 @@ function SourceReferenceBuildPanel({
|
||||
{frames.map((frame, index) => {
|
||||
const selected = selectedFrames.has(frame.index)
|
||||
return (
|
||||
<div
|
||||
<MediaAssetTile
|
||||
key={frame.index}
|
||||
onMouseEnter={(event) => updateFramePreviewPosition(event, frame.index)}
|
||||
onMouseMove={(event) => updateFramePreviewPosition(event, frame.index)}
|
||||
onMouseLeave={() => setFramePreview(null)}
|
||||
className={`group relative aspect-[9/16] overflow-hidden rounded border bg-black transition ${
|
||||
selected ? "border-emerald-300/70" : "border-white/10 hover:border-cyan-300/40"
|
||||
}`}
|
||||
src={effectiveFrameUrl(job.id, frame)}
|
||||
alt={`关键帧 ${index + 1}`}
|
||||
label={`参考帧 ${String(index + 1).padStart(2, "0")}`}
|
||||
meta={`${frame.timestamp.toFixed(1)}s`}
|
||||
className="aspect-[9/16]"
|
||||
objectFit="contain"
|
||||
selected={selected}
|
||||
title={`关键帧 ${index + 1} · ${frame.timestamp.toFixed(1)}s`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleFrame(frame.index)}
|
||||
className="absolute inset-0 cursor-pointer overflow-hidden focus:outline-none focus:ring-1 focus:ring-cyan-200/70"
|
||||
>
|
||||
<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>
|
||||
onClick={() => onToggleFrame(frame.index)}
|
||||
topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
|
||||
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>}
|
||||
onDelete={onDeleteFrame ? () => void deleteReferenceFrame(frame.index) : undefined}
|
||||
deleting={deletingFrame === frame.index}
|
||||
deleteLabel={`删除关键帧 ${index + 1}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{!frames.length && (
|
||||
@@ -2151,53 +2055,30 @@ function SourceReferenceBuildPanel({
|
||||
{visibleActorAssets.map((asset) => {
|
||||
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
onMouseEnter={(event) => updateSubjectAssetPreviewPosition(event, asset.id)}
|
||||
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
|
||||
<MediaAssetTile
|
||||
key={asset.id}
|
||||
src={subjectAssetUrl(job, asset)}
|
||||
href={subjectAssetUrl(job, asset)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<img src={subjectAssetUrl(job, asset)} alt={asset.label || asset.view} className="h-full w-full object-contain" />
|
||||
</a>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
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"
|
||||
aria-label={`重新生成${asset.label || asset.view}`}
|
||||
title="重新生成这一张"
|
||||
>
|
||||
{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>
|
||||
alt={asset.label || asset.view}
|
||||
label={asset.label || asset.view || "主体视图预览"}
|
||||
meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
|
||||
className="aspect-[9/16] w-12 bg-white 2xl:w-14"
|
||||
objectFit="contain"
|
||||
title={asset.label || asset.view}
|
||||
actions={[{
|
||||
key: "regen",
|
||||
label: "重新生成这一张",
|
||||
icon: <RefreshCw className="h-3 w-3" />,
|
||||
tone: "cyan",
|
||||
busy: busyMode === "regen",
|
||||
disabled: !!subjectAssetBusy,
|
||||
onClick: () => void regenerateSubjectAsset(asset),
|
||||
}]}
|
||||
onDelete={() => void deleteActorAsset(asset)}
|
||||
deleting={busyMode === "delete"}
|
||||
deleteDisabled={!!subjectAssetBusy}
|
||||
deleteLabel="删除这一张"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@@ -2216,11 +2097,13 @@ function AudioStoryboardPlanPanel({
|
||||
job,
|
||||
selectedFrames,
|
||||
onJobUpdate,
|
||||
onDeleteVideo,
|
||||
runtimeModels,
|
||||
}: {
|
||||
job: Job | null
|
||||
selectedFrames: Set<number>
|
||||
onJobUpdate?: (job: Job) => void
|
||||
onDeleteVideo?: (videoId: string) => void
|
||||
runtimeModels?: RuntimeModels
|
||||
}) {
|
||||
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) => {
|
||||
if (!job || !frame) return
|
||||
setStoryboardSaveBusyRow(row.index)
|
||||
@@ -2868,16 +2771,20 @@ function AudioStoryboardPlanPanel({
|
||||
frame={referenceFrame}
|
||||
role="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)}
|
||||
onGenerate={() => void generateEndpointFrameForRow(plannedRow, referenceFrame, "first_frame")}
|
||||
onDelete={() => void clearEndpointFrameForRow(plannedRow, referenceFrame, "first_frame")}
|
||||
/>
|
||||
<EndpointFrameSlot
|
||||
job={job}
|
||||
frame={referenceFrame}
|
||||
role="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)}
|
||||
onGenerate={() => void generateEndpointFrameForRow(plannedRow, referenceFrame, "last_frame")}
|
||||
onDelete={() => void clearEndpointFrameForRow(plannedRow, referenceFrame, "last_frame")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-[10px] text-white/34">
|
||||
@@ -2898,6 +2805,7 @@ function AudioStoryboardPlanPanel({
|
||||
job={job}
|
||||
videos={rowVideos}
|
||||
enabled={!!endpointAssetRef(referenceFrame, "first_frame") && !!endpointAssetRef(referenceFrame, "last_frame")}
|
||||
onDeleteVideo={onDeleteVideo}
|
||||
/>
|
||||
<div className="mt-1 truncate text-[10px] text-white/34" title="视频生成已暂停,首尾帧确认后再开放单条提交">
|
||||
{endpointAssetRef(referenceFrame, "first_frame") && endpointAssetRef(referenceFrame, "last_frame")
|
||||
@@ -2949,59 +2857,30 @@ function ProductReferenceCard({
|
||||
const assetWarnings = item.assetMeta?.warnings ?? []
|
||||
const assetActions = item.assetMeta?.actions ?? []
|
||||
const orientationText = formatProductOrientation(item.orientation)
|
||||
const [previewPos, setPreviewPos] = useState<{ left: number; top: number } | null>(null)
|
||||
|
||||
function updatePreviewPosition(event: ReactMouseEvent<HTMLDivElement>) {
|
||||
const margin = 16
|
||||
const previewWidth = Math.min(380, window.innerWidth - margin * 2)
|
||||
const previewHeight = previewWidth + 118
|
||||
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
|
||||
}
|
||||
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
|
||||
const previewDetail = (
|
||||
<>
|
||||
{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}
|
||||
</>
|
||||
)
|
||||
|
||||
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="relative h-[74px] w-[74px] rounded-md border border-white/10 bg-white"
|
||||
onMouseEnter={updatePreviewPosition}
|
||||
onMouseMove={updatePreviewPosition}
|
||||
onMouseLeave={() => setPreviewPos(null)}
|
||||
>
|
||||
<img src={src} alt={productViewLabel(item.view)} className="h-full w-full rounded-md object-contain" />
|
||||
{preview}
|
||||
<span className="absolute left-1 top-1 rounded bg-black/70 px-1 text-[9px] text-white/75">{item.source === "ai" ? "AI" : "图"}</span>
|
||||
</div>
|
||||
<MediaAssetTile
|
||||
src={src}
|
||||
alt={productViewLabel(item.view)}
|
||||
label={productViewLabel(item.view)}
|
||||
meta={formatProductAssetSize(item.assetMeta)}
|
||||
previewDetail={previewDetail}
|
||||
className="h-[74px] w-[74px] bg-white"
|
||||
objectFit="contain"
|
||||
topLeft={<span className="rounded bg-black/70 px-1 text-[9px] text-white/75">{item.source === "ai" ? "AI" : "图"}</span>}
|
||||
/>
|
||||
<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">
|
||||
<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 emptyCount = Math.max(0, 6 - visible.length)
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{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) => (
|
||||
<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,
|
||||
role,
|
||||
busy,
|
||||
deleting,
|
||||
disabled,
|
||||
onGenerate,
|
||||
onDelete,
|
||||
}: {
|
||||
job: Job
|
||||
frame: KeyFrame | null
|
||||
role: "first_frame" | "last_frame"
|
||||
busy: boolean
|
||||
deleting?: boolean
|
||||
disabled: boolean
|
||||
onGenerate: () => void
|
||||
onDelete?: () => void
|
||||
}) {
|
||||
const ref = endpointAssetRef(frame, role)
|
||||
const src = ref ? resolveImageRefUrl(job.id, ref) : ""
|
||||
const label = role === "first_frame" ? "首帧" : "尾帧"
|
||||
return (
|
||||
<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">
|
||||
{src ? (
|
||||
<a href={src} target="_blank" rel="noreferrer" className="group h-full w-full">
|
||||
<img src={src} alt={`${label}资产`} className="h-full w-full object-contain transition group-hover:scale-[1.02]" />
|
||||
</a>
|
||||
) : (
|
||||
<div className="px-2 text-center text-[10px] leading-snug text-white/28">先生成{label}</div>
|
||||
)}
|
||||
{busy && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/65">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-white/80" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MediaAssetTile
|
||||
src={src}
|
||||
href={src || undefined}
|
||||
alt={`${label}资产`}
|
||||
label={`${label}资产`}
|
||||
className="aspect-[9/16] min-h-[112px] border-0"
|
||||
objectFit="contain"
|
||||
busy={busy}
|
||||
emptyText={`先生成${label}`}
|
||||
onDelete={src && onDelete ? onDelete : undefined}
|
||||
deleting={deleting}
|
||||
deleteLabel={`移除${label}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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 poster = videoPoster(job, video)
|
||||
const running = video.status === "queued" || video.status === "in_progress"
|
||||
return (
|
||||
<a
|
||||
<MediaAssetTile
|
||||
kind="video"
|
||||
src={src && video.status === "completed" ? src : undefined}
|
||||
poster={poster}
|
||||
href={src || undefined}
|
||||
target={src ? "_blank" : undefined}
|
||||
rel={src ? "noreferrer" : undefined}
|
||||
className={`group relative shrink-0 overflow-hidden rounded border border-white/10 bg-black/45 ${className}`}
|
||||
alt={`片段 ${shortId(video.id)}`}
|
||||
label={`${shortId(video.id)} · ${video.model}`}
|
||||
meta={video.status}
|
||||
className={`shrink-0 bg-black/45 ${className}`}
|
||||
objectFit="cover"
|
||||
title={`${video.model} · ${video.status}`}
|
||||
>
|
||||
{src && video.status === "completed" ? (
|
||||
<video src={src} poster={poster} muted playsInline className="h-full w-full object-cover" />
|
||||
) : poster ? (
|
||||
<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>
|
||||
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>}
|
||||
topRight={running ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100" /> : undefined}
|
||||
onDelete={onDelete}
|
||||
deleteLabel="删除这个视频候选"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
234
web/components/media-asset-tile.tsx
Normal file
234
web/components/media-asset-tile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user