fix: lift filmstrip hover preview

This commit is contained in:
2026-05-19 18:29:12 +08:00
parent f574ab4775
commit 7604ed1dfe
3 changed files with 105 additions and 23 deletions

View File

@@ -15,7 +15,7 @@
## 部署事实 ## 部署事实
- 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik - 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik
- 发布状态已部署并验证2026-05-19逐句时间轴窄版面板 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片贴近波形并原位顶层大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401认证后首页 200容器内 `/health` 返回 `ok:true` - 发布状态已部署并验证2026-05-19逐句时间轴窄版面板 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401认证后首页 200容器内 `/health` 返回 `ok:true`
- 主站 / 前端:`https://marketing.skg.com` - 主站 / 前端:`https://marketing.skg.com`
- API / 后端:`https://marketing.skg.com/api` - API / 后端:`https://marketing.skg.com/api`
- 代码仓库 / Gitea`https://git.kang-kang.com/kangwan/20260512-skg-tk` - 代码仓库 / Gitea`https://git.kang-kang.com/kangwan/20260512-skg-tk`

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react" import { type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom" import { createPortal } from "react-dom"
import { import {
AlertTriangle, BookOpen, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, AlertTriangle, BookOpen, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2,
@@ -117,6 +117,17 @@ type FilmstripPreviewFrame = {
time: number time: number
src: string src: string
} }
type FilmstripHoverPreview = {
src: string
time: number
left: number
top: number
width: number
height: number
active: boolean
selected: boolean
busy: boolean
}
const FILMSTRIP_DRAG_TYPE = "application/x-skg-filmstrip-time" const FILMSTRIP_DRAG_TYPE = "application/x-skg-filmstrip-time"
const FILMSTRIP_DENSITIES: Array<{ value: FilmstripDensitySeconds; label: string; detail: string }> = [ const FILMSTRIP_DENSITIES: Array<{ value: FilmstripDensitySeconds; label: string; detail: string }> = [
@@ -125,6 +136,8 @@ const FILMSTRIP_DENSITIES: Array<{ value: FilmstripDensitySeconds; label: string
{ value: 1, label: "高", detail: "1s/张" }, { value: 1, label: "高", detail: "1s/张" },
] ]
const FILMSTRIP_TILT_CLASSES = ["-rotate-[8deg]", "-rotate-[6deg]", "-rotate-[9deg]"] const FILMSTRIP_TILT_CLASSES = ["-rotate-[8deg]", "-rotate-[6deg]", "-rotate-[9deg]"]
const FILMSTRIP_VERTICAL_OFFSET_CLASSES = ["translate-y-0", "translate-y-2", "-translate-y-1.5", "translate-y-1", "-translate-y-2"]
const FILMSTRIP_HOVER_SCALE = 4.8
type AudioStoryboardRow = { type AudioStoryboardRow = {
index: number index: number
@@ -2739,6 +2752,38 @@ function TimelineFilmstrip({
}) { }) {
const pointerPct = clampNumber((currentTime / Math.max(duration, 1)) * 100, 0, 100) const pointerPct = clampNumber((currentTime / Math.max(duration, 1)) * 100, 0, 100)
const hoverPct = hoverTime === null ? null : clampNumber((hoverTime / Math.max(duration, 1)) * 100, 0, 100) const hoverPct = hoverTime === null ? null : clampNumber((hoverTime / Math.max(duration, 1)) * 100, 0, 100)
const [hoverPreview, setHoverPreview] = useState<FilmstripHoverPreview | null>(null)
const showHoverPreview = (
event: ReactMouseEvent<HTMLDivElement>,
frame: FilmstripPreviewFrame,
active: boolean,
selected: boolean,
busy: boolean,
) => {
const tile = event.currentTarget.querySelector("[data-filmstrip-tile]")
const rect = (tile instanceof HTMLElement ? tile : event.currentTarget).getBoundingClientRect()
const width = rect.width * FILMSTRIP_HOVER_SCALE
const height = rect.height * FILMSTRIP_HOVER_SCALE
const margin = 10
const left = clampNumber(rect.left + rect.width / 2 - width / 2, margin, Math.max(margin, window.innerWidth - width - margin))
const top = clampNumber(rect.bottom - height - 12, margin, Math.max(margin, window.innerHeight - height - margin))
setHoverPreview({
src: frame.src,
time: frame.time,
left,
top,
width,
height,
active,
selected,
busy,
})
}
useEffect(() => {
if (!frames.length) setHoverPreview(null)
}, [frames.length])
return ( return (
<div className="relative z-[80] mt-1 overflow-visible pt-0.5"> <div className="relative z-[80] mt-1 overflow-visible pt-0.5">
@@ -2769,39 +2814,46 @@ function TimelineFilmstrip({
const active = Math.abs(currentTime - frame.time) <= Math.max(density * 0.45, 0.45) const active = Math.abs(currentTime - frame.time) <= Math.max(density * 0.45, 0.45)
const busy = busyTime !== null && Math.abs(busyTime - frame.time) < 0.45 const busy = busyTime !== null && Math.abs(busyTime - frame.time) < 0.45
const tiltClass = FILMSTRIP_TILT_CLASSES[index % FILMSTRIP_TILT_CLASSES.length] const tiltClass = FILMSTRIP_TILT_CLASSES[index % FILMSTRIP_TILT_CLASSES.length]
const verticalClass = FILMSTRIP_VERTICAL_OFFSET_CLASSES[index % FILMSTRIP_VERTICAL_OFFSET_CLASSES.length]
const framePct = clampNumber((frame.time / Math.max(duration, 1)) * 100, 0, 100) const framePct = clampNumber((frame.time / Math.max(duration, 1)) * 100, 0, 100)
return ( return (
<div <div
key={`${frame.time}-${index}`} key={`${frame.time}-${index}`}
draggable={!busy} draggable={!busy}
onMouseEnter={(event) => showHoverPreview(event, frame, active, selected, busy)}
onMouseMove={(event) => showHoverPreview(event, frame, active, selected, busy)}
onMouseLeave={() => setHoverPreview(null)}
onDragStart={(event) => { onDragStart={(event) => {
setHoverPreview(null)
event.dataTransfer.setData(FILMSTRIP_DRAG_TYPE, frame.time.toFixed(2)) event.dataTransfer.setData(FILMSTRIP_DRAG_TYPE, frame.time.toFixed(2))
event.dataTransfer.effectAllowed = "copy" event.dataTransfer.effectAllowed = "copy"
onDragStart(frame.time) onDragStart(frame.time)
}} }}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
className={`absolute bottom-[58px] z-20 -translate-x-1/2 ${tiltClass} origin-bottom cursor-grab transition-transform duration-150 will-change-transform hover:z-[9999] hover:-translate-y-3 hover:rotate-0 hover:scale-[4.8] active:cursor-grabbing`} className={`absolute bottom-[58px] z-20 -translate-x-1/2 ${verticalClass} ${tiltClass} origin-bottom cursor-grab transition-transform duration-150 will-change-transform hover:z-[90] hover:-translate-y-1 hover:rotate-0 active:cursor-grabbing`}
style={{ left: `${framePct}%` }} style={{ left: `${framePct}%` }}
title={`${frame.time.toFixed(1)}s · 拖到关键帧库才选取`} title={`${frame.time.toFixed(1)}s · 拖到关键帧库才选取`}
> >
<div className="absolute left-1/2 top-full h-4 w-px -translate-x-1/2 bg-white/18" /> <div className="absolute left-1/2 top-full h-4 w-px -translate-x-1/2 bg-white/18" />
<MediaAssetTile <div data-filmstrip-tile className="h-[72px] w-[42px]">
src={frame.src} <MediaAssetTile
alt={`胶片 ${frame.time.toFixed(1)}s`} src={frame.src}
label="临时胶片" alt={`胶片 ${frame.time.toFixed(1)}s`}
meta={`${frame.time.toFixed(1)}s`} label="临时胶片"
className={`h-[72px] w-[42px] rounded-md shadow-[0_10px_26px_rgba(0,0,0,0.36)] ${ meta={`${frame.time.toFixed(1)}s`}
active ? "ring-1 ring-[#d6b36a]/75" : "" className={`h-full w-full rounded-md shadow-[0_10px_26px_rgba(0,0,0,0.36)] ${
}`} active ? "ring-1 ring-[#d6b36a]/75" : ""
mediaClassName="bg-black" }`}
objectFit="contain" mediaClassName="bg-black"
disablePreview objectFit="contain"
selected={selected} disablePreview
onClick={() => onSeek(frame.time)} selected={selected}
title="点击跳到该时间点,拖入关键帧库才正式选取" onClick={() => onSeek(frame.time)}
topRight={busy ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100" /> : selected ? <Check className="h-3 w-3 text-emerald-200" /> : undefined} title="点击跳到该时间点,拖入关键帧库才正式选取"
bottom={<span className="block rounded bg-black/74 px-1 py-0.5 text-center font-mono text-[9px] text-white/68">{frame.time.toFixed(1)}s</span>} topRight={busy ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100" /> : selected ? <Check className="h-3 w-3 text-emerald-200" /> : undefined}
/> bottom={<span className="block rounded bg-black/74 px-1 py-0.5 text-center font-mono text-[9px] text-white/68">{frame.time.toFixed(1)}s</span>}
/>
</div>
</div> </div>
) )
})} })}
@@ -2816,6 +2868,36 @@ function TimelineFilmstrip({
<span>{formatSeconds(duration)}</span> <span>{formatSeconds(duration)}</span>
</div> </div>
</div> </div>
{hoverPreview && typeof document !== "undefined"
? createPortal(
<div
className="pointer-events-none fixed z-[10000]"
style={{
left: hoverPreview.left,
top: hoverPreview.top,
width: hoverPreview.width,
height: hoverPreview.height,
}}
>
<MediaAssetTile
src={hoverPreview.src}
alt={`胶片 ${hoverPreview.time.toFixed(1)}s`}
label="临时胶片"
meta={`${hoverPreview.time.toFixed(1)}s`}
className={`h-full w-full rounded-xl shadow-[0_24px_70px_rgba(0,0,0,0.45)] ${
hoverPreview.active ? "ring-1 ring-[#d6b36a]/80" : ""
}`}
mediaClassName="bg-black"
objectFit="contain"
disablePreview
selected={hoverPreview.selected}
topRight={hoverPreview.busy ? <Loader2 className="h-6 w-6 animate-spin text-cyan-100" /> : hoverPreview.selected ? <Check className="h-6 w-6 text-emerald-200" /> : undefined}
bottom={<span className="block rounded-md bg-black/74 px-2 py-1 text-center font-mono text-[42px] leading-none text-white/68">{hoverPreview.time.toFixed(1)}s</span>}
/>
</div>,
document.body,
)
: null}
</div> </div>
) )
} }