fix: lift filmstrip hover preview
This commit is contained in:
2
RULES.md
2
RULES.md
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user