feat: add source workspace layout tuning

This commit is contained in:
2026-05-20 21:14:23 +08:00
parent 97cca8d855
commit 0db265f086
2 changed files with 180 additions and 13 deletions

View File

@@ -4,7 +4,7 @@ import { type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, t
import { createPortal } from "react-dom"
import {
AlertTriangle, BookOpen, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, Minus,
MessageSquare, Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, Scissors, Send, Sparkles, Sun, Trash2, Upload, Wand2,
MessageSquare, Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, RotateCcw, Scissors, Send, SlidersHorizontal, Sparkles, Sun, Trash2, Upload, Wand2,
} from "lucide-react"
import { toast } from "sonner"
import {
@@ -108,6 +108,34 @@ const BOARD_FRAME_HEIGHT = 1000
const BOARD_MIN_SCALE = 0.72
const BOARD_MAX_SCALE = 1.6
const BOARD_SCALE_PRESETS = [0.72, 0.76, 0.8, 0.86, 0.92, 1, 1.06, 1.16, 1.24, 1.34, 1.48, 1.6]
const SOURCE_WORKSPACE_LAYOUT_STORAGE_KEY = "skg-source-workspace-layout:v1"
type SourceWorkspaceLayout = {
leftWidth: number
videoHeight: number
transcriptHeight: number
referenceWidth: number
conversionHeight: number
subjectEmptyHeight: number
}
const DEFAULT_SOURCE_WORKSPACE_LAYOUT: SourceWorkspaceLayout = {
leftWidth: 360,
videoHeight: 510,
transcriptHeight: 260,
referenceWidth: 140,
conversionHeight: 500,
subjectEmptyHeight: 78,
}
const SOURCE_WORKSPACE_LAYOUT_LIMITS: Record<keyof SourceWorkspaceLayout, { min: number; max: number; step: number; label: string; suffix: string }> = {
leftWidth: { min: 320, max: 460, step: 10, label: "左列宽", suffix: "px" },
videoHeight: { min: 430, max: 560, step: 10, label: "视频高", suffix: "px" },
transcriptHeight: { min: 180, max: 360, step: 10, label: "时间轴高", suffix: "px" },
referenceWidth: { min: 118, max: 180, step: 2, label: "参考池宽", suffix: "px" },
conversionHeight: { min: 420, max: 640, step: 10, label: "转换层高", suffix: "px" },
subjectEmptyHeight: { min: 56, max: 140, step: 4, label: "主体空态", suffix: "px" },
}
const resolveBoardScale = (viewportWidth: number) => {
const maxFitScale = clampNumber(viewportWidth / BOARD_FRAME_WIDTH, BOARD_MIN_SCALE, BOARD_MAX_SCALE)
@@ -718,6 +746,25 @@ function clampNumber(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function normalizeSourceWorkspaceLayout(value: Partial<SourceWorkspaceLayout> = {}): SourceWorkspaceLayout {
const next = { ...DEFAULT_SOURCE_WORKSPACE_LAYOUT, ...value }
return Object.fromEntries(
(Object.keys(DEFAULT_SOURCE_WORKSPACE_LAYOUT) as Array<keyof SourceWorkspaceLayout>).map((key) => {
const limits = SOURCE_WORKSPACE_LAYOUT_LIMITS[key]
return [key, clampNumber(Number(next[key]) || DEFAULT_SOURCE_WORKSPACE_LAYOUT[key], limits.min, limits.max)]
}),
) as SourceWorkspaceLayout
}
function loadSourceWorkspaceLayout() {
if (typeof window === "undefined") return DEFAULT_SOURCE_WORKSPACE_LAYOUT
try {
return normalizeSourceWorkspaceLayout(JSON.parse(window.localStorage.getItem(SOURCE_WORKSPACE_LAYOUT_STORAGE_KEY) || "{}"))
} catch {
return DEFAULT_SOURCE_WORKSPACE_LAYOUT
}
}
async function decodeAudioFeatures(url: string, targetFrames = 640): Promise<AudioFeature[]> {
const res = await fetch(url)
if (!res.ok) throw new Error(`audio ${res.status}`)
@@ -2729,6 +2776,62 @@ function MaterialColumn({
)
}
function SourceWorkspaceLayoutPanel({
layout,
onChange,
onReset,
}: {
layout: SourceWorkspaceLayout
onChange: (layout: SourceWorkspaceLayout) => void
onReset: () => void
}) {
const update = (key: keyof SourceWorkspaceLayout, value: number) => {
onChange(normalizeSourceWorkspaceLayout({ ...layout, [key]: value }))
}
return (
<div className="mb-2 rounded-md border border-[#d6b36a]/24 bg-[#d6b36a]/[0.055] p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div>
<div className="text-[10.5px] font-semibold text-[#f4dc88]"></div>
<div className="mt-0.5 text-[9px] text-white/36"></div>
</div>
<button
type="button"
onClick={onReset}
className="inline-flex h-7 items-center gap-1 rounded-md border border-white/10 bg-black/28 px-2 text-[10px] font-semibold text-white/48 transition hover:border-white/24 hover:text-white/76"
title="恢复推荐参数"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid grid-cols-3 gap-2">
{(Object.keys(SOURCE_WORKSPACE_LAYOUT_LIMITS) as Array<keyof SourceWorkspaceLayout>).map((key) => {
const item = SOURCE_WORKSPACE_LAYOUT_LIMITS[key]
return (
<label key={key} className="min-w-0 rounded border border-white/8 bg-black/18 px-2 py-1.5">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[9.5px] font-semibold text-white/56">{item.label}</span>
<span className="font-mono text-[9px] text-white/34">{layout[key]}{item.suffix}</span>
</div>
<input
type="range"
min={item.min}
max={item.max}
step={item.step}
value={layout[key]}
onChange={(event) => update(key, Number(event.target.value))}
className="h-4 w-full accent-[#d6b36a]"
/>
</label>
)
})}
</div>
</div>
)
}
function AudioIntakePanel({
job,
selectedFrames,
@@ -2759,6 +2862,8 @@ function AudioIntakePanel({
const [filmstripStatus, setFilmstripStatus] = useState<FilmstripStatus>("idle")
const [filmstripDragTime, setFilmstripDragTime] = useState<number | null>(null)
const [filmstripBusyTime, setFilmstripBusyTime] = useState<number | null>(null)
const [layoutOpen, setLayoutOpen] = useState(false)
const [workspaceLayout, setWorkspaceLayout] = useState<SourceWorkspaceLayout>(() => loadSourceWorkspaceLayout())
const videoRef = useRef<HTMLVideoElement | null>(null)
const transcriptScrollRef = useRef<HTMLDivElement | null>(null)
const rowRefs = useRef<Record<number, HTMLDivElement | null>>({})
@@ -2784,6 +2889,12 @@ function AudioIntakePanel({
? `当前句 ${activeSegment.start.toFixed(1)}-${activeSegment.end.toFixed(1)}s`
: "指针 -"
useEffect(() => {
try {
window.localStorage.setItem(SOURCE_WORKSPACE_LAYOUT_STORAGE_KEY, JSON.stringify(workspaceLayout))
} catch { /* ignore unavailable storage */ }
}, [workspaceLayout])
useEffect(() => {
if (!job?.id || !audioSrcUrl) {
setAudioFeatures([])
@@ -2959,21 +3070,49 @@ function AudioIntakePanel({
<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">
<SectionTitle icon={<Film className="h-4 w-4" />} title="源视频工作区" />
<div className="flex items-center gap-2 font-mono text-[11px] text-white/38">
<span>{job.transcript.length} </span>
<span>{formatSeconds(job.duration)}</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 font-mono text-[11px] text-white/38">
<span>{job.transcript.length} </span>
<span>{formatSeconds(job.duration)}</span>
</div>
<button
type="button"
onClick={() => setLayoutOpen((open) => !open)}
className={`inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-[10px] font-semibold transition ${
layoutOpen
? "border-[#d6b36a]/55 bg-[#d6b36a]/13 text-[#f4dc88]"
: "border-white/10 bg-black/24 text-white/42 hover:border-white/24 hover:text-white/70"
}`}
title="临时调节源视频工作区布局"
>
<SlidersHorizontal className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="grid gap-2 border-t border-white/8 pt-2">
{layoutOpen ? (
<SourceWorkspaceLayoutPanel
layout={workspaceLayout}
onChange={setWorkspaceLayout}
onReset={() => setWorkspaceLayout(DEFAULT_SOURCE_WORKSPACE_LAYOUT)}
/>
) : null}
<div className="grid gap-2">
<div className="grid grid-cols-[460px_minmax(0,1fr)] gap-3">
<div
className="grid gap-3"
style={{ gridTemplateColumns: `${workspaceLayout.leftWidth}px minmax(0,1fr)` }}
>
<div className="min-w-0 space-y-2">
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle icon={<Play className="h-4 w-4" />} title="原版视频" />
<span className="font-mono text-[11px] text-white/38">{currentTime.toFixed(1)}s</span>
</div>
<div className="relative mx-auto aspect-[9/16] h-[510px] overflow-hidden rounded-md border border-white/10 bg-black">
<div
className="relative mx-auto aspect-[9/16] overflow-hidden rounded-md border border-white/10 bg-black"
style={{ height: workspaceLayout.videoHeight }}
>
{job.video_url ? (
<video
ref={videoRef}
@@ -3012,6 +3151,7 @@ function AudioIntakePanel({
activeSegmentIndex={activeSegment?.index ?? null}
scrollRef={transcriptScrollRef}
rowRefs={rowRefs}
maxHeight={workspaceLayout.transcriptHeight}
onSeek={seekTo}
/>
</div>
@@ -3069,6 +3209,7 @@ function AudioIntakePanel({
runtimeModels={runtimeModels}
filmstripDragging={filmstripDragTime !== null}
onDropFilmstripFrame={(time) => addFilmstripFrame(time)}
layout={workspaceLayout}
/>
</div>
</div>
@@ -3085,6 +3226,7 @@ function TranscriptTimelinePanel({
activeSegmentIndex,
scrollRef,
rowRefs,
maxHeight,
onSeek,
}: {
job: Job
@@ -3092,6 +3234,7 @@ function TranscriptTimelinePanel({
activeSegmentIndex: number | null
scrollRef: RefObject<HTMLDivElement | null>
rowRefs: { current: Record<number, HTMLDivElement | null> }
maxHeight: number
onSeek: (time: number) => void
}) {
return (
@@ -3106,7 +3249,7 @@ function TranscriptTimelinePanel({
<div></div>
<div> / </div>
</div>
<div ref={scrollRef} className="max-h-[306px] overflow-y-auto">
<div ref={scrollRef} className="overflow-y-auto" style={{ maxHeight }}>
{job.transcript.map((segment) => {
const active = activeSegmentIndex === segment.index
return (
@@ -3364,6 +3507,7 @@ function SourceSubjectPipeline({
runtimeModels,
filmstripDragging,
onDropFilmstripFrame,
layout,
}: {
job: Job
frames: KeyFrame[]
@@ -3377,6 +3521,7 @@ function SourceSubjectPipeline({
runtimeModels?: RuntimeModels
filmstripDragging?: boolean
onDropFilmstripFrame?: (time: number) => Promise<KeyFrame | null> | KeyFrame | null | void
layout: SourceWorkspaceLayout
}) {
const [referenceDropActive, setReferenceDropActive] = useState(false)
const [agentDropActive, setAgentDropActive] = useState(false)
@@ -3939,7 +4084,10 @@ function SourceSubjectPipeline({
return (
<>
<div className="space-y-2">
<div className="grid grid-cols-[150px_minmax(0,1fr)] gap-2">
<div
className="grid gap-2"
style={{ gridTemplateColumns: `${layout.referenceWidth}px minmax(0,1fr)` }}
>
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-2">
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="参考帧池" />
@@ -3990,7 +4138,7 @@ function SourceSubjectPipeline({
<span>{frames.length} </span>
<span>{filmstripDragging ? "松手加入" : "点击选择"}</span>
</div>
<div className="flex max-h-[500px] flex-col gap-1 overflow-y-auto pr-0.5">
<div className="flex flex-col gap-1 overflow-y-auto pr-0.5" style={{ maxHeight: layout.conversionHeight }}>
{frames.map((frame, index) => {
const selected = selectedFrames.has(frame.index)
return (
@@ -4055,7 +4203,10 @@ function SourceSubjectPipeline({
{agentReferenceFrames.length ? `${agentReferenceFrames.length}/${RECONSTRUCTION_FRAME_LIMIT}` : "待选图"}
</span>
</div>
<div className="flex min-h-[500px] flex-col rounded-md border border-white/10 bg-black/24 p-2">
<div
className="flex flex-col overflow-y-auto rounded-md border border-white/10 bg-black/24 p-2"
style={{ height: layout.conversionHeight }}
>
<div className="mb-2 grid grid-cols-2 gap-1.5">
{SUBJECT_MODEL_BUNDLE_OPTIONS.map((option) => (
<button
@@ -4420,7 +4571,10 @@ function SourceSubjectPipeline({
</div>
</div>
) : (
<div className="flex h-20 items-center justify-center rounded border border-dashed border-white/12 px-3 text-center text-[10.5px] leading-snug text-white/34">
<div
className="flex items-center justify-center rounded border border-dashed border-white/12 px-3 text-center text-[10.5px] leading-snug text-white/34"
style={{ minHeight: layout.subjectEmptyHeight }}
>
</div>
)}