feat(studio): Phase 3 — scene reorder + numeric duration + FIX/FLEXIBLE gating

Wires the scene-list operations users asked for into the existing timeline
(model-agnostic — works for any scene, layer- or block-based):

- SceneThumbnailBlock: now sortable (@dnd-kit useSortable) with a left-edge grip
  handle (listeners only on the handle so select/rename/resize still work); adds a
  numeric per-scene duration input (commit on blur/Enter, clamped 1–30s) next to
  the drag-resize; a `locked` prop makes it read-only.
- SceneThumbnailStrip: wraps the blocks in DndContext + SortableContext
  (horizontal, 6px pointer-activation so clicks/resize aren't hijacked) and calls
  the existing reorderScenes store action; gates add/browse + reorder/duplicate/
  delete/duration behind isFixedSceneMode(chooseMode).
- studio-store: isFixedSceneMode() helper (single source for FIX vs FLEXIBLE).
- i18n: reorderScene / durationLabel / secondsUnit in fa + en.

Verified with `npm run build` (rules-of-hooks clean). NOTE: a THEME PICKER and
FlexStory block-FIELD editing are deferred — the studio editor is Konva-layer-
centric, so both need a FlexStory-aware editing path (a follow-up), not this phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-23 14:18:00 +03:30
parent f8ea9af3b6
commit 8ddca5647b
5 changed files with 172 additions and 50 deletions
+4 -1
View File
@@ -1224,7 +1224,10 @@
"deleteScene": "Delete {name}", "deleteScene": "Delete {name}",
"resizeSceneDuration": "Resize {name} duration", "resizeSceneDuration": "Resize {name} duration",
"sceneNameLabel": "Scene name", "sceneNameLabel": "Scene name",
"doubleClickToRename": "Double-click to rename" "doubleClickToRename": "Double-click to rename",
"reorderScene": "Reorder {name}",
"durationLabel": "Scene duration (seconds)",
"secondsUnit": "s"
}, },
"componentsStudioTimelineSceneThumbnailStrip": { "componentsStudioTimelineSceneThumbnailStrip": {
"browseScenes": "Browse scenes", "browseScenes": "Browse scenes",
+4 -1
View File
@@ -1224,7 +1224,10 @@
"deleteScene": "حذف {name}", "deleteScene": "حذف {name}",
"resizeSceneDuration": "تغییر مدت زمان {name}", "resizeSceneDuration": "تغییر مدت زمان {name}",
"sceneNameLabel": "نام صحنه", "sceneNameLabel": "نام صحنه",
"doubleClickToRename": "برای تغییر نام، دوبار کلیک کنید" "doubleClickToRename": "برای تغییر نام، دوبار کلیک کنید",
"reorderScene": "جابه‌جایی {name}",
"durationLabel": "مدت صحنه (ثانیه)",
"secondsUnit": "ث"
}, },
"componentsStudioTimelineSceneThumbnailStrip": { "componentsStudioTimelineSceneThumbnailStrip": {
"browseScenes": "مرور صحنه‌ها", "browseScenes": "مرور صحنه‌ها",
@@ -3,7 +3,9 @@
import Image from "next/image"; import Image from "next/image";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Copy, Trash2 } from "lucide-react"; import { Copy, GripVertical, Trash2 } from "lucide-react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { import {
clampSceneDuration, clampSceneDuration,
@@ -19,6 +21,8 @@ interface SceneThumbnailBlockProps {
colorIndex: number; colorIndex: number;
isActive: boolean; isActive: boolean;
canDelete: boolean; canDelete: boolean;
/** FIX-mode lock: hides reorder/duplicate/delete + makes duration read-only. */
locked?: boolean;
pxPerSecond?: number; pxPerSecond?: number;
onSelect: () => void; onSelect: () => void;
onRename: (name: string) => void; onRename: (name: string) => void;
@@ -32,6 +36,7 @@ export function SceneThumbnailBlock({
colorIndex, colorIndex,
isActive, isActive,
canDelete, canDelete,
locked = false,
pxPerSecond = STRIP_PX_PER_SECOND, pxPerSecond = STRIP_PX_PER_SECOND,
onSelect, onSelect,
onRename, onRename,
@@ -40,6 +45,8 @@ export function SceneThumbnailBlock({
onDelete, onDelete,
}: SceneThumbnailBlockProps) { }: SceneThumbnailBlockProps) {
const t = useTranslations("auto.componentsStudioTimelineSceneThumbnailBlock"); const t = useTranslations("auto.componentsStudioTimelineSceneThumbnailBlock");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: scene.id, disabled: locked });
const [resizeDuration, setResizeDuration] = useState<number | null>(null); const [resizeDuration, setResizeDuration] = useState<number | null>(null);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(scene.name); const [editName, setEditName] = useState(scene.name);
@@ -106,7 +113,16 @@ export function SceneThumbnailBlock({
); );
return ( return (
<div className="shrink-0" style={{ width }}> <div
ref={setNodeRef}
className="shrink-0"
style={{
width,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
}}
>
{/* ── Thumbnail block ── */} {/* ── Thumbnail block ── */}
<div <div
role="button" role="button"
@@ -147,7 +163,22 @@ export function SceneThumbnailBlock({
{durationLabel} {durationLabel}
</span> </span>
{/* Drag handle (reorder) — left edge, hover-revealed */}
{!locked && (
<div
{...attributes}
{...listeners}
role="button"
aria-label={t("reorderScene", { name: scene.name })}
onClick={(event) => event.stopPropagation()}
className="absolute left-0 top-0 z-30 flex h-full w-4 cursor-grab items-center justify-center opacity-0 transition-opacity hover:bg-black/20 group-hover:opacity-100 active:cursor-grabbing"
>
<GripVertical className="h-3.5 w-3.5 text-white/90" aria-hidden />
</div>
)}
{/* Action buttons — top-right, revealed on hover */} {/* Action buttons — top-right, revealed on hover */}
{!locked && (
<div className="absolute right-1 top-1 z-10 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"> <div className="absolute right-1 top-1 z-10 flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button <button
type="button" type="button"
@@ -174,6 +205,7 @@ export function SceneThumbnailBlock({
</button> </button>
) : null} ) : null}
</div> </div>
)}
{/* Duration bar — thin colored bar across the bottom */} {/* Duration bar — thin colored bar across the bottom */}
<div <div
@@ -183,17 +215,19 @@ export function SceneThumbnailBlock({
/> />
{/* Right-edge drag handle to resize duration */} {/* Right-edge drag handle to resize duration */}
<div {!locked && (
role="separator" <div
aria-orientation="vertical" role="separator"
aria-label={t("resizeSceneDuration", { name: scene.name })} aria-orientation="vertical"
onMouseDown={(event) => { aria-label={t("resizeSceneDuration", { name: scene.name })}
event.stopPropagation(); onMouseDown={(event) => {
event.preventDefault(); event.stopPropagation();
handleResizeStart(event.clientX); event.preventDefault();
}} handleResizeStart(event.clientX);
className="absolute right-0 top-0 z-20 h-full w-2 cursor-ew-resize hover:bg-white/20" }}
/> className="absolute right-0 top-0 z-20 h-full w-2 cursor-ew-resize hover:bg-white/20"
/>
)}
</div> </div>
{/* ── Scene name below the block ── */} {/* ── Scene name below the block ── */}
@@ -228,6 +262,37 @@ export function SceneThumbnailBlock({
{scene.name} {scene.name}
</p> </p>
)} )}
{/* Numeric duration input (commit on blur/Enter); read-only when locked. */}
{locked ? (
<p className="mt-0.5 text-center text-[10px] tabular-nums text-gray-400">
{scene.duration}
{t("secondsUnit")}
</p>
) : (
<div className="mt-0.5 flex items-center justify-center gap-1">
<input
key={scene.duration}
type="number"
min={1}
max={30}
step={0.1}
defaultValue={scene.duration}
onClick={(event) => event.stopPropagation()}
onBlur={(event) =>
onDurationChange(
clampSceneDuration(parseFloat(event.target.value) || scene.duration)
)
}
onKeyDown={(event) => {
if (event.key === "Enter") (event.target as HTMLInputElement).blur();
}}
className="w-11 rounded border border-gray-200 bg-white px-1 py-0.5 text-center text-[10px] tabular-nums text-gray-700 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500"
aria-label={t("durationLabel")}
/>
<span className="text-[9px] text-gray-400">{t("secondsUnit")}</span>
</div>
)}
</div> </div>
); );
} }
@@ -3,6 +3,15 @@
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { LayoutGrid, Plus } from "lucide-react"; import { LayoutGrid, Plus } from "lucide-react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { SortableContext, horizontalListSortingStrategy } from "@dnd-kit/sortable";
import { SceneBrowserModal } from "@/components/studio/SceneBrowserModal"; import { SceneBrowserModal } from "@/components/studio/SceneBrowserModal";
import { SceneThumbnailBlock } from "@/components/studio/timeline/SceneThumbnailBlock"; import { SceneThumbnailBlock } from "@/components/studio/timeline/SceneThumbnailBlock";
@@ -13,7 +22,7 @@ import {
getSceneTimelineSegments, getSceneTimelineSegments,
STRIP_PX_PER_SECOND, STRIP_PX_PER_SECOND,
} from "@/lib/studio-timeline"; } from "@/lib/studio-timeline";
import { useStudioStore } from "@/lib/studio-store"; import { useStudioStore, isFixedSceneMode } from "@/lib/studio-store";
interface SceneThumbnailStripProps { interface SceneThumbnailStripProps {
onSceneSelect?: () => void; onSceneSelect?: () => void;
@@ -34,6 +43,22 @@ export function SceneThumbnailStrip({ onSceneSelect }: SceneThumbnailStripProps)
const updateScene = useStudioStore((state) => state.updateScene); const updateScene = useStudioStore((state) => state.updateScene);
const duplicateScene = useStudioStore((state) => state.duplicateScene); const duplicateScene = useStudioStore((state) => state.duplicateScene);
const deleteScene = useStudioStore((state) => state.deleteScene); const deleteScene = useStudioStore((state) => state.deleteScene);
const reorderScenes = useStudioStore((state) => state.reorderScenes);
const chooseMode = useStudioStore((state) => state.chooseMode);
// FIX-mode templates have an immutable scene structure (from AE layer names);
// FLEXIBLE (incl. the scene-engine) lets users add/remove/reorder/retime.
const fixed = isFixedSceneMode(chooseMode);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = scenes.findIndex((s) => s.id === active.id);
const to = scenes.findIndex((s) => s.id === over.id);
if (from !== -1 && to !== -1) reorderScenes(from, to);
};
const segments = useMemo(() => getSceneTimelineSegments(scenes), [scenes]); const segments = useMemo(() => getSceneTimelineSegments(scenes), [scenes]);
const totalDuration = useMemo(() => getProjectDuration(scenes), [scenes]); const totalDuration = useMemo(() => getProjectDuration(scenes), [scenes]);
@@ -63,42 +88,58 @@ export function SceneThumbnailStrip({ onSceneSelect }: SceneThumbnailStripProps)
{/* Scene blocks */} {/* Scene blocks */}
<div className="relative flex items-end gap-1.5 px-2 pb-1 pt-1"> <div className="relative flex items-end gap-1.5 px-2 pb-1 pt-1">
{segments.map(({ scene, index }) => ( <DndContext
<SceneThumbnailBlock sensors={sensors}
key={scene.id} collisionDetection={closestCenter}
scene={scene} onDragEnd={handleDragEnd}
pxPerSecond={STRIP_PX_PER_SECOND}
colorIndex={index}
isActive={scene.id === activeSceneId}
canDelete={canDelete}
onSelect={() => {
setActiveScene(scene.id);
onSceneSelect?.();
}}
onRename={(name) => updateScene(scene.id, { name })}
onDurationChange={(duration) => updateScene(scene.id, { duration })}
onDuplicate={() => duplicateScene(scene.id)}
onDelete={() => deleteScene(scene.id)}
/>
))}
<button
type="button"
onClick={() => setBrowserOpen(true)}
className="mb-5 flex h-20 w-10 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-400 transition-colors hover:border-blue-400 hover:bg-blue-50 hover:text-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label={t("browseScenes")}
> >
<LayoutGrid className="h-5 w-5" aria-hidden /> <SortableContext
</button> items={scenes.map((s) => s.id)}
strategy={horizontalListSortingStrategy}
>
{segments.map(({ scene, index }) => (
<SceneThumbnailBlock
key={scene.id}
scene={scene}
pxPerSecond={STRIP_PX_PER_SECOND}
colorIndex={index}
isActive={scene.id === activeSceneId}
canDelete={canDelete}
locked={fixed}
onSelect={() => {
setActiveScene(scene.id);
onSceneSelect?.();
}}
onRename={(name) => updateScene(scene.id, { name })}
onDurationChange={(duration) => updateScene(scene.id, { duration })}
onDuplicate={() => duplicateScene(scene.id)}
onDelete={() => deleteScene(scene.id)}
/>
))}
</SortableContext>
</DndContext>
<button {!fixed && (
type="button" <>
onClick={() => addScene()} <button
className="mb-5 flex h-20 w-10 shrink-0 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 text-gray-300 transition-colors hover:border-blue-400 hover:text-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500" type="button"
aria-label={t("addScene")} onClick={() => setBrowserOpen(true)}
> className="mb-5 flex h-20 w-10 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-400 transition-colors hover:border-blue-400 hover:bg-blue-50 hover:text-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
<Plus className="h-5 w-5" aria-hidden /> aria-label={t("browseScenes")}
</button> >
<LayoutGrid className="h-5 w-5" aria-hidden />
</button>
<button
type="button"
onClick={() => addScene()}
className="mb-5 flex h-20 w-10 shrink-0 items-center justify-center rounded-lg border-2 border-dashed border-gray-300 text-gray-300 transition-colors hover:border-blue-400 hover:text-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label={t("addScene")}
>
<Plus className="h-5 w-5" aria-hidden />
</button>
</>
)}
<TimelinePlayhead <TimelinePlayhead
currentTime={currentTime} currentTime={currentTime}
+10
View File
@@ -784,6 +784,16 @@ export const useStudioStore = create<StudioStore>((set, get) => {
}; };
}); });
/**
* FIXED scene modes have an immutable scene structure (scenes come from AE comp /
* layer names), so the studio hides add/duplicate/delete/reorder + duration
* editing. FLEXIBLE (incl. the FlexStory scene engine) allows all of them.
*/
const FIXED_SCENE_MODES = new Set(["fix", "musicvisualizer"]);
export function isFixedSceneMode(chooseMode: string | null | undefined): boolean {
return FIXED_SCENE_MODES.has((chooseMode ?? "").toLowerCase());
}
export function getActiveScene( export function getActiveScene(
state: Pick<StudioState, "scenes" | "activeSceneId"> state: Pick<StudioState, "scenes" | "activeSceneId">
): Scene | undefined { ): Scene | undefined {