feat: standardize media asset tiles
This commit is contained in:
@@ -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`
|
||||||
|
- 所有媒体缩略图默认支持鼠标停留放大预览;预览层必须挂到顶层固定浮层,不能被滚动容器、面板或表格裁切
|
||||||
|
- 可删除的媒体素材必须显示删除入口;删除按钮、重新生成按钮、状态遮罩和悬停预览交互要在全项目保持一致
|
||||||
|
- 缩略图尺寸可按区域调整,但图片 / 视频必须可完整查看;需要裁切时必须仍能通过悬停预览看到完整素材
|
||||||
|
- 新增媒体板块验收时必须检查:悬停放大、删除入口、预览不被遮挡、图片完整性、视频预览、模型 / 状态标注是否与已有板块一致
|
||||||
|
|||||||
1
RULES.md
1
RULES.md
@@ -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
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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