'use client'; /** * ActionsBar — Pro-mode "讲解脚本" bottom bar, a horizontal film-editing timeline * that is also a light editor for the scene's playback `actions`. * * The scene's `actions` ARE the timeline: walked left→right, each `speech` * becomes an editable clip block (one spoken line, numbered) and every non-speech * cue (spotlight / laser / board) becomes a compact card pinned at its place in * the flow. Hovering a cue replays the REAL playback effect on its bound element * (setLaser → LaserPointerOverlay, setSpotlight → SpotlightOverlay). * * Editing (persisted via useStageStore.updateScene → actions-edit ops): * - speech clip text is editable inline (commit on blur); * - the header "添加动作" pill opens ActionPicker to insert a new action; * - existing items drag to reorder; each card carries a delete button; * - clicking an element-bound cue arms canvas pick mode (useCanvasStore.pickTarget), * so the target is chosen by clicking the element directly on the slide. * * Collapsible; height-resizable from the top edge; reactive to the stage store. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Flag, FoldVertical, GripVertical, Play, Plus, RefreshCw, Trash2, UnfoldVertical, Volume2, } from 'lucide-react'; import { motion, useReducedMotion } from 'motion/react'; import { cn } from '@/lib/utils/cn'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useStageStore } from '@/lib/store/stage'; import { useCanvasStore } from '@/lib/store/canvas'; import { useSettingsStore } from '@/lib/store/settings'; import { useAgentRegistry } from '@/lib/orchestration/registry/store'; import { AvatarDisplay } from '@/components/ui/avatar-display'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import type { Action, DiscussionAction } from '@/lib/types/action'; import type { SceneType } from '@/lib/types/stage'; import { ELEMENT_BOUND, cueLabel, cueMeta, elementLabel } from './cue-meta'; import { applyCuePreview, clearCuePreview, cuePreviewFor } from './cue-preview'; import { appendDiscussion, clampInsertSlot, hasDiscussion, insertAt, makeAction, moveById, moveByIdDir, removeById, setAudioIdById, setDiscussionAgentById, setDiscussionPromptById, setDiscussionTopicById, setSpeechTextClearAudioById, } from './actions-edit'; import { ActionPicker } from './ActionPicker'; import type { PickerType } from './picker-options'; import { audioExists, audioObjectUrl, discardSpeechAudio, regenerateSpeechAudio, resolveSpeechAudioId, speechAudioId, } from '@/lib/audio/regenerate-speech-tts'; const EMPTY: Action[] = []; const EMPTY_ELEMENTS: { id?: string; type: string; content?: string }[] = []; // Stable empty set for the "no lines regenerating" state (avoids re-allocating // on every reset and keeps a constant identity between batch runs). const NO_IDS: ReadonlySet = new Set(); /** * Clear the canvas spotlight/laser preview when a cue glyph unmounts while it is * being hovered — most importantly when the user deletes the cue. React does not * fire `onMouseLeave` on unmount, so without this the previewed effect would stay * stuck on the slide after its cue is gone. */ function useClearCuePreviewOnUnmount() { useEffect(() => () => clearCuePreview(), []); } /** * Soft amber dashed border marking a still-incomplete clip card — an empty * narration line, a cue bound to no element, a discussion with no topic. A clip * is a card, so a dashed frame reads as "draft / unfinished" better than a dot; * the calmer amber stays clear of the blue interactive controls and is dropped * the moment the clip is filled. */ const INCOMPLETE_CLIP = 'border-dashed border-amber-400/70'; const MIN_H = 168; const MAX_H = 520; const DEFAULT_H = 224; const LINE_H = 86; // height when collapsed to just the axis line of node icons (fits the chips) const AXIS_FROM_TOP = 20; // px from track top to the axis center (nodes hang below it) // Radix Select forbids an empty-string item value, so the discussion's // "unspecified agent" choice rides a sentinel that maps back to '' on change. const DISCUSSION_AGENT_NONE = '__none__'; type DragPayload = { kind: 'move'; id: string }; interface TooltipState { action: Action; anchor: DOMRect; } type TFn = (key: string, options?: Record) => string; function propsOf(a: Action, t: TFn): Array<[string, string]> { const rows: Array<[string, string]> = [[t('edit.timeline.fieldAction'), cueLabel(a.type, t)]]; const el = (a as { elementId?: string }).elementId; if (el) rows.push([t('edit.timeline.fieldElement'), el]); const content = (a as { content?: string }).content; if (content) rows.push([ t('edit.timeline.fieldContent'), content.length > 48 ? `${content.slice(0, 48)}…` : content, ]); return rows; } function CueTooltip({ tip }: { tip: TooltipState }) { const { t } = useI18n(); if (typeof document === 'undefined') return null; return createPortal(
{propsOf(tip.action, t).map(([k, v]) => (
{k} {v}
))}
, document.body, ); } // Native HTML5 drag snapshots the element's square bounding box, so a round // icon chip drags with white corners ("白边"). Suppress the ghost with a 1×1 // transparent image — the violet drop indicator carries the feedback instead. let blankDragImg: HTMLImageElement | null = null; function setBlankDragImage(e: React.DragEvent) { if (typeof document === 'undefined') return; if (!blankDragImg) { blankDragImg = new Image(); blankDragImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; } try { e.dataTransfer.setDragImage(blankDragImg, 0, 0); } catch { /* not supported — fall back to the default ghost */ } } /** Shared delete button — prominent, top-right of a card. */ function DeleteButton({ onDelete }: { onDelete: () => void }) { const { t } = useI18n(); return ( ); } /** ‹ › buttons to nudge a node left/right along the timeline. */ function MoveButtons({ onLeft, onRight, canLeft, canRight, }: { onLeft: () => void; onRight: () => void; canLeft: boolean; canRight: boolean; }) { const { t } = useI18n(); const cls = 'grid size-5 place-items-center rounded text-muted-foreground/55 transition-colors hover:bg-muted hover:text-foreground disabled:opacity-25 disabled:hover:bg-transparent'; return ( <> ); } type TtsStatus = 'none' | 'ready' | 'generating' | 'error'; /** Audio status + 试听 / 重新生成 row, shown when managed TTS is on. */ function SpeechTtsBar({ actionId, audioId, sceneOrder, language, text, audioUrl, refreshKey, regenerating, onGenerated, }: { actionId: string; audioId?: string; sceneOrder: number; language?: string; text: string; audioUrl?: string; refreshKey?: number; regenerating?: boolean; onGenerated: () => void; }) { const { t } = useI18n(); const [status, setStatus] = useState('none'); // Holds this line in 生成中 across a batch ("全部配音") run and — crucially — // until its OWN audio re-check resolves, so it can't briefly flash back to // 未配音 in the window between the batch clearing `regenerating` and the async // audioExists effect landing. Latched on the rising edge of `regenerating`, // cleared inside that re-check effect (which the batch always re-triggers via // `refreshKey`). const [batchPending, setBatchPending] = useState(false); const [prevRegenerating, setPrevRegenerating] = useState(regenerating); if (regenerating !== prevRegenerating) { // Adjust state during render (per React's "you might not need an effect"), // not in an effect — avoids a cascading render on the batch's hot path. setPrevRegenerating(regenerating); if (regenerating) setBatchPending(true); } const audioRef = useRef(null); const objUrlRef = useRef(null); // The audio's real key: the action's stamped audioId, else the canonical // derived key (resolveSpeechAudioId is the single source of truth). const lookupId = resolveSpeechAudioId(sceneOrder, { id: actionId, audioId }); const stopPreview = useCallback(() => { audioRef.current?.pause(); audioRef.current = null; if (objUrlRef.current) { URL.revokeObjectURL(objUrlRef.current); objUrlRef.current = null; } }, []); useEffect(() => { let alive = true; (async () => { try { if (audioUrl) { if (alive) setStatus('ready'); return; } const has = await audioExists(lookupId); if (alive) setStatus((s) => (s === 'generating' ? s : has ? 'ready' : 'none')); } catch { /* IndexedDB read failed — leave status as-is (as before this change) */ } finally { // Clear the batch latch only once the batch itself is over — its // end-of-batch re-check runs with regenerating=false. A *stale* // pre-batch check that resolves mid-batch must NOT clear it (adding // regenerating to the deps also cancels such a check at batch start via // the cleanup below). Runs even if the read threw, so the row can never // wedge in 生成中. if (alive && !regenerating) setBatchPending(false); } })(); return () => { alive = false; }; }, [lookupId, audioUrl, refreshKey, regenerating]); useEffect(() => () => stopPreview(), [stopPreview]); const preview = async () => { stopPreview(); let src = audioUrl ?? null; if (!src) { src = await audioObjectUrl(lookupId); objUrlRef.current = src; } if (!src) return; const a = new Audio(src); audioRef.current = a; a.addEventListener('ended', stopPreview); void a.play().catch(() => stopPreview()); }; const regenerate = async () => { setStatus('generating'); try { const id = await regenerateSpeechAudio(sceneOrder, { id: actionId, text }, language); if (id) { onGenerated(); setStatus('ready'); } else { setStatus('none'); } } catch { setStatus('error'); } }; const STATUS: Record = { ready: { label: t('edit.tts.statusReady'), cls: 'text-emerald-600 dark:text-emerald-400' }, none: { label: t('edit.tts.statusNone'), cls: 'text-muted-foreground' }, generating: { label: t('edit.tts.statusGenerating'), cls: 'text-amber-600 dark:text-amber-400', }, error: { label: t('edit.tts.statusError'), cls: 'text-rose-500' }, }; // A batch "全部配音" run drives this line's loading state from the parent // (regenerating) — independent of the local single-line status. `batchPending` // extends 生成中 past the prop clearing, until this line's own audio re-check // resolves to 已配音 / 未配音, so the batch end shows a clean 生成中 → 已配音 // transition with no intermediate flash. const effStatus: TtsStatus = regenerating || batchPending ? 'generating' : status; const s = STATUS[effStatus]; return (
{s.label}
); } /** One spoken line — a numbered, editable clip block. */ function SpeechClip({ text, index, actionId, audioId, sceneOrder, language, autoFocus, ttsActive, audioUrl, ttsRefresh, regenerating, onCommit, onGenerated, onDelete, onMoveLeft, onMoveRight, canMoveLeft, canMoveRight, onDragStart, onDragEnd, onFocused, }: { text: string; index: number; actionId: string; audioId?: string; sceneOrder: number; language?: string; autoFocus: boolean; ttsActive: boolean; audioUrl?: string; ttsRefresh?: number; regenerating?: boolean; onCommit: (text: string) => void; onGenerated: () => void; onDelete: () => void; onMoveLeft: () => void; onMoveRight: () => void; canMoveLeft: boolean; canMoveRight: boolean; onDragStart: (e: React.DragEvent) => void; onDragEnd: () => void; onFocused: () => void; }) { const { t } = useI18n(); const ref = useRef(null); const [val, setVal] = useState(text); // Has the user typed since the last external sync? If not, external text // changes (e.g. an agent regeneration mid-edit) are adopted even while // focused — so a stale draft can't clobber regenerated narration on blur. const dirtyRef = useRef(false); useEffect(() => { if (document.activeElement !== ref.current || !dirtyRef.current) { // eslint-disable-next-line react-hooks/set-state-in-effect -- sync external text in only when not mid-edit setVal(text); dirtyRef.current = false; } }, [text]); useEffect(() => { if (autoFocus) { ref.current?.focus(); onFocused(); } }, [autoFocus, onFocused]); const commit = () => { if (dirtyRef.current && val !== text) onCommit(val); dirtyRef.current = false; }; const SpeechIcon = cueMeta('speech').icon; const needsText = !text.trim(); return (
{String(index).padStart(2, '0')} {t('edit.cue.speech')}