fix: add temporary filmstrip frame picker
This commit is contained in:
4
RULES.md
4
RULES.md
@@ -11,11 +11,11 @@
|
||||
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
||||
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
||||
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
||||
- 当前产品方向(2026-05-19 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧,供人工选择可用主体并生成相似主体白底视图。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
|
||||
- 当前产品方向(2026-05-19 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧,供人工选择可用主体并生成相似主体白底视图。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,拖进参考帧池才正式加入关键帧。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
|
||||
|
||||
## 部署事实
|
||||
- 平台: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,逐句时间轴窄版面板 + 波形下方临时画面胶片选帧 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401,认证后首页 200;容器内 `/health` 返回 `ok:true`
|
||||
- 主站 / 前端:`https://marketing.skg.com`
|
||||
- API / 后端:`https://marketing.skg.com/api`
|
||||
- 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -111,6 +111,21 @@ type AudioFeature = {
|
||||
|
||||
type AudioFeatureStatus = "idle" | "loading" | "ready" | "failed"
|
||||
|
||||
type FilmstripDensitySeconds = 1 | 2 | 5
|
||||
type FilmstripStatus = "idle" | "loading" | "ready" | "failed"
|
||||
type FilmstripPreviewFrame = {
|
||||
time: number
|
||||
src: string
|
||||
}
|
||||
|
||||
const FILMSTRIP_DRAG_TYPE = "application/x-skg-filmstrip-time"
|
||||
const FILMSTRIP_DENSITIES: Array<{ value: FilmstripDensitySeconds; label: string; detail: string }> = [
|
||||
{ value: 5, label: "低", detail: "5s/张" },
|
||||
{ value: 2, label: "中", detail: "2s/张" },
|
||||
{ value: 1, label: "高", detail: "1s/张" },
|
||||
]
|
||||
const FILMSTRIP_TILT_CLASSES = ["-rotate-[8deg]", "-rotate-[6deg]", "-rotate-[9deg]"]
|
||||
|
||||
type AudioStoryboardRow = {
|
||||
index: number
|
||||
start: number
|
||||
@@ -575,6 +590,102 @@ async function decodeAudioFeatures(url: string, targetFrames = 640): Promise<Aud
|
||||
}
|
||||
}
|
||||
|
||||
function waitForMediaEvent(target: HTMLMediaElement, eventName: string, timeoutMs = 12000) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`${eventName} timeout`))
|
||||
}, timeoutMs)
|
||||
const cleanup = () => {
|
||||
window.clearTimeout(timer)
|
||||
target.removeEventListener(eventName, onReady)
|
||||
target.removeEventListener("error", onError)
|
||||
}
|
||||
const onReady = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const onError = () => {
|
||||
cleanup()
|
||||
reject(new Error("video load failed"))
|
||||
}
|
||||
target.addEventListener(eventName, onReady, { once: true })
|
||||
target.addEventListener("error", onError, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function waitForVideoSeek(video: HTMLVideoElement, time: number) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error("seek timeout"))
|
||||
}, 8000)
|
||||
const cleanup = () => {
|
||||
window.clearTimeout(timer)
|
||||
video.removeEventListener("seeked", onSeeked)
|
||||
video.removeEventListener("error", onError)
|
||||
}
|
||||
const onSeeked = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const onError = () => {
|
||||
cleanup()
|
||||
reject(new Error("video seek failed"))
|
||||
}
|
||||
video.addEventListener("seeked", onSeeked, { once: true })
|
||||
video.addEventListener("error", onError, { once: true })
|
||||
video.currentTime = time
|
||||
})
|
||||
}
|
||||
|
||||
async function captureVideoFilmstrip(
|
||||
url: string,
|
||||
duration: number,
|
||||
step: FilmstripDensitySeconds,
|
||||
shouldCancel: () => boolean,
|
||||
): Promise<FilmstripPreviewFrame[]> {
|
||||
if (!url || duration <= 0) return []
|
||||
const video = document.createElement("video")
|
||||
video.muted = true
|
||||
video.playsInline = true
|
||||
video.preload = "auto"
|
||||
video.src = url
|
||||
video.load()
|
||||
if (video.readyState < 1) await waitForMediaEvent(video, "loadedmetadata")
|
||||
const sourceDuration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : duration
|
||||
const usableDuration = Math.max(Math.min(duration || sourceDuration, sourceDuration), 0.1)
|
||||
const times: number[] = []
|
||||
for (let time = 0; time < usableDuration; time += step) {
|
||||
times.push(clampNumber(time + 0.08, 0, Math.max(usableDuration - 0.05, 0)))
|
||||
}
|
||||
if (!times.length) times.push(0.05)
|
||||
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = 96
|
||||
canvas.height = 170
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) throw new Error("canvas unavailable")
|
||||
const frames: FilmstripPreviewFrame[] = []
|
||||
for (const time of times) {
|
||||
if (shouldCancel()) break
|
||||
await waitForVideoSeek(video, time)
|
||||
if (shouldCancel()) break
|
||||
ctx.fillStyle = "#050505"
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
const vw = video.videoWidth || canvas.width
|
||||
const vh = video.videoHeight || canvas.height
|
||||
const scale = Math.min(canvas.width / vw, canvas.height / vh)
|
||||
const dw = vw * scale
|
||||
const dh = vh * scale
|
||||
ctx.drawImage(video, (canvas.width - dw) / 2, (canvas.height - dh) / 2, dw, dh)
|
||||
frames.push({ time: Number(time.toFixed(2)), src: canvas.toDataURL("image/jpeg", 0.68) })
|
||||
}
|
||||
video.removeAttribute("src")
|
||||
video.load()
|
||||
return frames
|
||||
}
|
||||
|
||||
function frameLabel(frame: KeyFrame, order: number) {
|
||||
return `S${String(order + 1).padStart(2, "0")} · ${frame.timestamp.toFixed(1)}s`
|
||||
}
|
||||
@@ -2257,10 +2368,16 @@ function AudioIntakePanel({
|
||||
const [extracting, setExtracting] = useState(false)
|
||||
const [deletingFrame, setDeletingFrame] = useState<number | null>(null)
|
||||
const [waveHoverTime, setWaveHoverTime] = useState<number | null>(null)
|
||||
const [filmstripDensity, setFilmstripDensity] = useState<FilmstripDensitySeconds>(2)
|
||||
const [filmstripPreviews, setFilmstripPreviews] = useState<FilmstripPreviewFrame[]>([])
|
||||
const [filmstripStatus, setFilmstripStatus] = useState<FilmstripStatus>("idle")
|
||||
const [filmstripDragTime, setFilmstripDragTime] = useState<number | null>(null)
|
||||
const [filmstripBusyTime, setFilmstripBusyTime] = useState<number | null>(null)
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const rowRefs = useRef<Record<number, HTMLDivElement | null>>({})
|
||||
const syncFrameRef = useRef<number | null>(null)
|
||||
const audioSrcUrl = job ? apiAssetUrl(job.source_audio_url) || sourceAudioUrl(job.id) : ""
|
||||
const videoSrcUrl = job ? apiAssetUrl(job.video_url) || videoUrl(job.id) : ""
|
||||
const processing = !!job && (job.status === "transcribing" || job.audio_script?.status === "rewriting")
|
||||
const timelineDuration = useMemo(() => {
|
||||
if (!job) return 1
|
||||
@@ -2315,6 +2432,31 @@ function AudioIntakePanel({
|
||||
if (activeSegment) rowRefs.current[activeSegment.index]?.scrollIntoView({ block: "nearest" })
|
||||
}, [activeSegment?.index])
|
||||
|
||||
useEffect(() => {
|
||||
if (!job?.id || !job.video_url || !videoSrcUrl || timelineDuration <= 0) {
|
||||
setFilmstripPreviews([])
|
||||
setFilmstripStatus("idle")
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setFilmstripPreviews([])
|
||||
setFilmstripStatus("loading")
|
||||
captureVideoFilmstrip(videoSrcUrl, timelineDuration, filmstripDensity, () => cancelled)
|
||||
.then((frames) => {
|
||||
if (!cancelled) {
|
||||
setFilmstripPreviews(frames)
|
||||
setFilmstripStatus(frames.length ? "ready" : "idle")
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setFilmstripPreviews([])
|
||||
setFilmstripStatus("failed")
|
||||
}
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [filmstripDensity, job?.id, job?.video_url, timelineDuration, videoSrcUrl])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (syncFrameRef.current !== null) cancelAnimationFrame(syncFrameRef.current)
|
||||
@@ -2360,6 +2502,24 @@ function AudioIntakePanel({
|
||||
}
|
||||
}
|
||||
|
||||
const addFilmstripFrame = async (time: number) => {
|
||||
if (!job || !onAddFrame) return
|
||||
const next = clampNumber(time, 0, timelineDuration)
|
||||
const duplicate = frames.find((frame) => Math.abs(frame.timestamp - next) < 0.45)
|
||||
if (duplicate) {
|
||||
toast.warning(`附近已有关键帧:${duplicate.timestamp.toFixed(1)}s`)
|
||||
return
|
||||
}
|
||||
setFilmstripBusyTime(next)
|
||||
try {
|
||||
await onAddFrame(job.id, next)
|
||||
toast.success(`已加入关键帧:${next.toFixed(1)}s`)
|
||||
} finally {
|
||||
setFilmstripBusyTime(null)
|
||||
setFilmstripDragTime(null)
|
||||
}
|
||||
}
|
||||
|
||||
const extractKeyframes = async () => {
|
||||
if (!job) return
|
||||
setExtracting(true)
|
||||
@@ -2391,8 +2551,6 @@ function AudioIntakePanel({
|
||||
return <EmptyState text="先在左侧粘贴 TK 链接或上传本地视频。点击开始后,会先下载视频,再自动解析原音频文案、讲话人节奏和背景音。" />
|
||||
}
|
||||
|
||||
const videoSrcUrl = apiAssetUrl(job.video_url) || videoUrl(job.id)
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-white/10 bg-black/28 p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
@@ -2466,6 +2624,18 @@ function AudioIntakePanel({
|
||||
onSeek={seekTo}
|
||||
onHoverTimeChange={setWaveHoverTime}
|
||||
/>
|
||||
<TimelineFilmstrip
|
||||
frames={filmstripPreviews}
|
||||
status={filmstripStatus}
|
||||
density={filmstripDensity}
|
||||
currentTime={currentTime}
|
||||
selectedTimes={frames.map((frame) => frame.timestamp)}
|
||||
busyTime={filmstripBusyTime}
|
||||
onDensityChange={setFilmstripDensity}
|
||||
onSeek={seekTo}
|
||||
onDragStart={setFilmstripDragTime}
|
||||
onDragEnd={() => setFilmstripDragTime(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 xl:grid-cols-[360px_minmax(0,1fr)] 2xl:grid-cols-[400px_minmax(0,1fr)]">
|
||||
@@ -2479,6 +2649,8 @@ function AudioIntakePanel({
|
||||
onToggleFrame={onToggleFrame}
|
||||
onExtract={() => void extractKeyframes()}
|
||||
onDeleteFrame={onDeleteFrame ? (idx) => void deleteReferenceFrame(idx) : undefined}
|
||||
filmstripDragging={filmstripDragTime !== null}
|
||||
onDropFilmstripFrame={(time) => void addFilmstripFrame(time)}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 xl:max-w-[620px] 2xl:max-w-[680px]">
|
||||
@@ -2536,6 +2708,117 @@ function AudioIntakePanel({
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineFilmstrip({
|
||||
frames,
|
||||
status,
|
||||
density,
|
||||
currentTime,
|
||||
selectedTimes,
|
||||
busyTime,
|
||||
onDensityChange,
|
||||
onSeek,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: {
|
||||
frames: FilmstripPreviewFrame[]
|
||||
status: FilmstripStatus
|
||||
density: FilmstripDensitySeconds
|
||||
currentTime: number
|
||||
selectedTimes: number[]
|
||||
busyTime: number | null
|
||||
onDensityChange: (density: FilmstripDensitySeconds) => void
|
||||
onSeek: (time: number) => void
|
||||
onDragStart: (time: number) => void
|
||||
onDragEnd: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 rounded-md border border-white/10 bg-black/30 p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold text-white/68">画面胶片</div>
|
||||
<div className="mt-0.5 text-[10px] text-white/34">临时预览,拖到关键帧才选取</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{FILMSTRIP_DENSITIES.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => onDensityChange(item.value)}
|
||||
aria-label={`胶片密度:${item.detail}`}
|
||||
className={`h-7 rounded-md border px-2 text-[10.5px] font-semibold transition ${
|
||||
density === item.value
|
||||
? "border-[#d6b36a]/70 bg-[#d6b36a]/18 text-[#f7df9a]"
|
||||
: "border-white/10 bg-white/[0.035] text-white/48 hover:border-white/22 hover:text-white/72"
|
||||
}`}
|
||||
title={item.detail}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[118px] overflow-x-auto overflow-y-hidden px-2 pb-8 pt-4">
|
||||
{status === "loading" ? (
|
||||
<div className="flex h-[72px] items-center justify-center gap-2 rounded-md border border-dashed border-white/12 text-[11px] text-white/40">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
正在生成临时胶片
|
||||
</div>
|
||||
) : status === "failed" ? (
|
||||
<div className="flex h-[72px] items-center justify-center rounded-md border border-dashed border-rose-200/20 text-[11px] text-rose-100/68">
|
||||
胶片预览生成失败,可继续用当前点抽帧。
|
||||
</div>
|
||||
) : frames.length ? (
|
||||
<div className="flex min-w-max items-end pl-1">
|
||||
{frames.map((frame, index) => {
|
||||
const selected = selectedTimes.some((time) => Math.abs(time - frame.time) < 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 tiltClass = FILMSTRIP_TILT_CLASSES[index % FILMSTRIP_TILT_CLASSES.length]
|
||||
return (
|
||||
<div
|
||||
key={`${frame.time}-${index}`}
|
||||
draggable={!busy}
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData(FILMSTRIP_DRAG_TYPE, frame.time.toFixed(2))
|
||||
event.dataTransfer.effectAllowed = "copy"
|
||||
onDragStart(frame.time)
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
className={`relative shrink-0 ${index ? "-ml-2.5" : ""} ${tiltClass} cursor-grab transition-transform duration-150 hover:z-30 hover:rotate-0 hover:scale-[2.15] active:cursor-grabbing`}
|
||||
title={`${frame.time.toFixed(1)}s · 拖到关键帧库才选取`}
|
||||
>
|
||||
<MediaAssetTile
|
||||
src={frame.src}
|
||||
alt={`胶片 ${frame.time.toFixed(1)}s`}
|
||||
label="临时胶片"
|
||||
meta={`${frame.time.toFixed(1)}s`}
|
||||
className={`h-[72px] w-[42px] rounded-md shadow-[0_10px_26px_rgba(0,0,0,0.36)] ${
|
||||
active ? "ring-1 ring-[#d6b36a]/75" : ""
|
||||
}`}
|
||||
mediaClassName="bg-black"
|
||||
objectFit="contain"
|
||||
previewObjectFit="contain"
|
||||
selected={selected}
|
||||
onClick={() => onSeek(frame.time)}
|
||||
title="点击跳到该时间点,拖入关键帧库才正式选取"
|
||||
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 className="flex h-[72px] items-center justify-center rounded-md border border-dashed border-white/12 text-[11px] text-white/34">
|
||||
等待原视频生成临时胶片
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceKeyframePicker({
|
||||
job,
|
||||
frames,
|
||||
@@ -2546,6 +2829,8 @@ function SourceKeyframePicker({
|
||||
onToggleFrame,
|
||||
onExtract,
|
||||
onDeleteFrame,
|
||||
filmstripDragging,
|
||||
onDropFilmstripFrame,
|
||||
}: {
|
||||
job: Job
|
||||
frames: KeyFrame[]
|
||||
@@ -2556,7 +2841,10 @@ function SourceKeyframePicker({
|
||||
onToggleFrame: (idx: number) => void
|
||||
onExtract: () => void
|
||||
onDeleteFrame?: (idx: number) => void
|
||||
filmstripDragging?: boolean
|
||||
onDropFilmstripFrame?: (time: number) => void
|
||||
}) {
|
||||
const [dropActive, setDropActive] = useState(false)
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
@@ -2578,10 +2866,41 @@ function SourceKeyframePicker({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[205px] rounded-md border border-white/10 bg-black/32 p-1.5 2xl:min-h-[260px]">
|
||||
<div
|
||||
className={`min-h-[205px] rounded-md border p-1.5 transition 2xl:min-h-[260px] ${
|
||||
filmstripDragging
|
||||
? dropActive
|
||||
? "border-[#d6b36a]/80 bg-[#d6b36a]/12 ring-1 ring-[#d6b36a]/45"
|
||||
: "border-[#d6b36a]/45 bg-[#d6b36a]/[0.065]"
|
||||
: "border-white/10 bg-black/32"
|
||||
}`}
|
||||
onDragEnter={(event) => {
|
||||
if (!onDropFilmstripFrame) return
|
||||
event.preventDefault()
|
||||
setDropActive(true)
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!onDropFilmstripFrame) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = "copy"
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
const next = event.relatedTarget as Node | null
|
||||
if (next && event.currentTarget.contains(next)) return
|
||||
setDropActive(false)
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!onDropFilmstripFrame) return
|
||||
event.preventDefault()
|
||||
setDropActive(false)
|
||||
const raw = event.dataTransfer.getData(FILMSTRIP_DRAG_TYPE)
|
||||
const time = Number(raw)
|
||||
if (Number.isFinite(time)) onDropFilmstripFrame(time)
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-white/34">参考帧池</span>
|
||||
<span className="text-[9.5px] text-white/28">点选即为已选,悬停放大</span>
|
||||
<span className="text-[9.5px] text-white/28">{filmstripDragging ? "松手加入关键帧" : "拖入胶片选帧,悬停放大"}</span>
|
||||
</div>
|
||||
<div className="grid max-h-[178px] grid-cols-[repeat(auto-fill,minmax(38px,1fr))] gap-1 overflow-y-auto pr-0.5 2xl:max-h-[232px]">
|
||||
{frames.map((frame, index) => {
|
||||
|
||||
Reference in New Issue
Block a user