- {DIRECTOR_TEMPLATES.map((template) => (
+
+ {PREVIS_TEMPLATES.map((template) => (
))}
diff --git a/web/src/components/canvas/previs/canvas-previs-workbench.css b/web/src/components/canvas/previs/canvas-previs-workbench.css
new file mode 100644
index 000000000..8bf8b70f3
--- /dev/null
+++ b/web/src/components/canvas/previs/canvas-previs-workbench.css
@@ -0,0 +1,1494 @@
+/* ============================================================
+ Previs Workbench — Design System
+ Awwwards-quality rewrite. All values are tokens; no magic numbers.
+ OKLCH color ramp · modular type scale · unified easing · coherent
+ active states · 3-level elevation · 3 radii · motion with purpose.
+ ============================================================ */
+
+/* ── Design tokens ──────────────────────────────────────────────────────────
+ Surface ramp: s0 (deepest) → s5 (highest).
+ OKLCH notation: L (0-1) C (0-0.4) H (hue degrees)
+ ─────────────────────────────────────────────────────────────────────────── */
+[data-canvas-previs-workbench].previs-desk {
+ /* Surfaces — cool-blue-tinted dark ramp */
+ --pd-s0: oklch(0.08 0.018 240); /* deepest bg */
+ --pd-s1: oklch(0.11 0.018 238); /* panel bg */
+ --pd-s2: oklch(0.14 0.018 236); /* raised surface */
+ --pd-s3: oklch(0.17 0.020 234); /* hover surface */
+ --pd-s4: oklch(0.21 0.022 232); /* active surface */
+ --pd-s5: oklch(0.25 0.024 230); /* elevated (menu bg) */
+
+ /* Borders */
+ --pd-b0: oklch(1 0 0 / 0.08); /* subtle separator */
+ --pd-b1: oklch(1 0 0 / 0.16); /* standard border */
+ --pd-b2: oklch(1 0 0 / 0.28); /* strong border */
+
+ /* Text */
+ --pd-t0: oklch(0.97 0.008 240); /* primary text */
+ --pd-t1: oklch(0.70 0.016 240); /* secondary text */
+ --pd-t2: oklch(0.50 0.016 240); /* muted text */
+ --pd-t3: oklch(0.36 0.014 240); /* faint text */
+
+ --pd-a0: var(--previs-accent, oklch(0.72 0.18 240));
+ --pd-a1: var(--previs-accent, oklch(0.80 0.14 240));
+ --pd-a-bg: var(--pd-a-bg-theme, oklch(0.72 0.18 240 / 0.12));
+ --pd-a-bd: var(--pd-a-bd-theme, oklch(0.72 0.18 240 / 0.40));
+ --pd-a-on: var(--pd-a-on-theme, #fff);
+
+ /* Semantic */
+ --pd-green: oklch(0.75 0.14 155); /* success */
+ --pd-orange: oklch(0.78 0.12 60); /* warning */
+ --pd-red: oklch(0.65 0.18 22); /* danger */
+
+ /* Type scale (base 12px) */
+ --pd-ts-base: 12px;
+ --pd-ts-sm: 11px;
+ --pd-ts-xs: 10px;
+ --pd-ts-2xs: 9px;
+ --pd-ts-lg: 13px;
+ --pd-ts-xl: 15px;
+ --pd-ts-2xl: 20px;
+
+ /* Radii — 3 values only */
+ --pd-r-sm: 4px;
+ --pd-r-md: 8px;
+ --pd-r-lg: 12px;
+
+ /* Easing */
+ --pd-ease: cubic-bezier(0.16, 1, 0.3, 1);
+ --pd-ease-snap: cubic-bezier(0.4, 0, 0.2, 1);
+ --pd-ease-out: cubic-bezier(0, 0, 0.3, 1);
+
+ /* Elevation shadows */
+ --pd-shadow-raised: 0 1px 3px oklch(0 0 0 / 0.24), 0 1px 2px oklch(0 0 0 / 0.16);
+ --pd-shadow-floating: 0 8px 32px oklch(0 0 0 / 0.48), 0 2px 8px oklch(0 0 0 / 0.28);
+
+ /* Layout dimensions */
+ --pd-topbar: 50px;
+ --pd-sidebar: 238px;
+ --pd-inspector: 340px;
+ --pd-timeline: 286px;
+
+ /* Legacy aliases — keep existing JS that reads these */
+ --pd-bg: var(--pd-s0);
+ --pd-surface: var(--pd-s1);
+ --pd-surface-2: var(--pd-s2);
+ --pd-surface-3: var(--pd-s3);
+ --pd-line: var(--pd-b0);
+ --pd-line-strong: var(--pd-b1);
+ --pd-text: var(--pd-t0);
+ --pd-muted: var(--pd-t1);
+ --pd-faint: var(--pd-t2);
+ --pd-accent: var(--previs-accent, #4f8cff);
+ --pd-accent-2: var(--pd-a1);
+
+ background: var(--pd-s0) !important;
+ color: var(--pd-t0) !important;
+ font-size: var(--pd-ts-base);
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* ── Global reset inside workbench ────────────────────────────────────────── */
+.previs-desk,
+.previs-desk > *,
+.previs-desk-header,
+.pv-layout,
+.pv-panel,
+.pv-viewport-area { min-width: 0; }
+.previs-desk * { box-sizing: border-box; }
+.previs-desk button,
+.previs-desk input,
+.previs-desk select,
+.previs-desk textarea { font: inherit; }
+.previs-desk button { color: inherit; cursor: pointer; }
+.previs-desk button:disabled { cursor: default; }
+
+/* ── Workbench entrance animation ─────────────────────────────────────────── */
+@keyframes pd-appear {
+ from { opacity: 0; transform: scale(0.993) translateY(4px); }
+ to { opacity: 1; transform: none; }
+}
+[data-canvas-previs-workbench].previs-desk {
+ animation: pd-appear 280ms var(--pd-ease) both;
+}
+@media (prefers-reduced-motion: reduce) {
+ [data-canvas-previs-workbench].previs-desk { animation: none; }
+}
+
+/* ── Header ───────────────────────────────────────────────────────────────── */
+.previs-desk-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: var(--pd-topbar);
+ min-width: 0;
+ overflow: hidden;
+ padding: 0 12px;
+ border-bottom: 1px solid var(--pd-b0);
+ background: var(--pd-s1);
+ flex: 0 0 var(--pd-topbar);
+ /* Subtle top-border highlight */
+ box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.06);
+}
+
+.previs-desk-brand {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 217px;
+}
+
+/* Logo mark — film-frame motif */
+.previs-desk-logo {
+ display: grid;
+ place-items: center;
+ width: 40px;
+ height: 26px;
+ border-radius: var(--pd-r-sm);
+ background: linear-gradient(135deg, oklch(0.92 0.04 240), oklch(0.98 0.01 240));
+ color: var(--pd-s0);
+ font-weight: 900;
+ font-size: 15px;
+ letter-spacing: -0.02em;
+ flex: 0 0 40px;
+ box-shadow: 0 1px 3px oklch(0 0 0 / 0.3);
+}
+
+.previs-desk-brand-title {
+ font-size: var(--pd-ts-lg);
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ color: var(--pd-t0);
+}
+
+.previs-desk-brand-sub {
+ font-size: var(--pd-ts-2xs);
+ color: var(--pd-t2);
+ letter-spacing: 0.03em;
+ margin-left: 2px;
+}
+
+.previs-desk-divider {
+ width: 1px;
+ height: 20px;
+ background: var(--pd-b0);
+ flex: 0 0 auto;
+ margin: 0 2px;
+}
+
+.previs-desk-scene-title {
+ width: 180px;
+ min-width: 120px;
+ height: 30px;
+ border: 1px solid var(--pd-b1);
+ border-radius: var(--pd-r-sm);
+ background: var(--pd-s0);
+ color: var(--pd-t0);
+ padding: 0 8px;
+ font-size: var(--pd-ts-base);
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ transition: border-color 150ms var(--pd-ease-snap),
+ background 150ms var(--pd-ease-snap),
+ box-shadow 150ms var(--pd-ease-snap);
+}
+.previs-desk-scene-title:hover {
+ border-color: var(--pd-b2);
+ background: var(--pd-s2);
+}
+.previs-desk-scene-title:focus-visible {
+ border-color: var(--pd-a0);
+ background: var(--pd-s2);
+ outline: none;
+ box-shadow: 0 0 0 3px var(--pd-a-bg);
+}
+
+/* Undo/redo history buttons */
+.previs-desk-history { display: flex; gap: 2px; }
+
+.previs-desk-history button,
+.previs-desk-icon-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 0;
+ border-radius: var(--pd-r-sm);
+ background: transparent;
+ color: var(--pd-t2);
+ transition: background 120ms var(--pd-ease-snap), color 120ms var(--pd-ease-snap);
+}
+.previs-desk-history button:hover:not(:disabled),
+.previs-desk-icon-button:hover:not(:disabled) {
+ background: var(--pd-s3);
+ color: var(--pd-t0);
+}
+.previs-desk-history button:disabled,
+.previs-desk-icon-button:disabled { opacity: 0.32; }
+.previs-desk-history button:focus-visible,
+.previs-desk-icon-button:focus-visible {
+ outline: 2px solid var(--pd-a0);
+ outline-offset: 2px;
+}
+
+/* Mode switch — pill style */
+.previs-mode-switch-button {
+ display: inline-flex;
+ align-items: center;
+ height: 28px;
+ padding: 0 12px;
+ border: 1px solid var(--pd-b0);
+ border-radius: var(--pd-r-sm);
+ background: transparent;
+ color: var(--pd-t2);
+ font-size: var(--pd-ts-sm);
+ font-weight: 500;
+ letter-spacing: 0.01em;
+ white-space: nowrap;
+ transition: background 140ms var(--pd-ease-snap),
+ border-color 140ms var(--pd-ease-snap),
+ color 140ms var(--pd-ease-snap);
+}
+.previs-mode-switch-button:hover {
+ background: var(--pd-s3);
+ color: var(--pd-t0);
+ border-color: var(--pd-b1);
+}
+.previs-mode-switch-button.is-active {
+ background: var(--pd-s4);
+ border-color: var(--pd-b2);
+ color: var(--pd-t0);
+ font-weight: 600;
+}
+.previs-mode-switch-button:focus-visible {
+ outline: 2px solid var(--pd-a0);
+ outline-offset: 2px;
+}
+
+/* Tab nav (contextual layout) */
+.previs-desk-tabs { display: flex; align-items: center; gap: 2px; margin-left: 4px; }
+.previs-desk-tab {
+ height: 28px;
+ padding: 0 11px;
+ border: 1px solid transparent;
+ border-radius: var(--pd-r-sm);
+ background: transparent;
+ color: var(--pd-t2);
+ font-size: var(--pd-ts-sm);
+ font-weight: 500;
+ transition: background 120ms var(--pd-ease-snap),
+ color 120ms var(--pd-ease-snap),
+ border-color 120ms var(--pd-ease-snap);
+}
+.previs-desk-tab:hover { color: var(--pd-t0); background: var(--pd-s2); }
+.previs-desk-tab.is-active {
+ color: var(--pd-t0);
+ background: var(--pd-s4);
+ border-color: var(--pd-b1);
+}
+.previs-desk-tab:focus-visible, .previs-desk-tool-button:focus-visible, .previs-view-toolbar-button:focus-visible {
+ outline: 2px solid var(--pd-a0);
+ outline-offset: 2px;
+}
+
+/* View toolbar */
+.previs-view-toolbar-inline {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ padding: 2px;
+ border: 1px solid var(--pd-b0);
+ border-radius: var(--pd-r-sm);
+ background: var(--pd-s0);
+}
+.previs-view-toolbar-inline .previs-view-toolbar-button {
+ height: 24px;
+ min-width: 40px;
+ padding: 0 9px;
+ border-radius: calc(var(--pd-r-sm) - 1px);
+ color: var(--pd-t2);
+ font-size: var(--pd-ts-xs);
+ transition: background 120ms var(--pd-ease-snap), color 120ms var(--pd-ease-snap);
+}
+.previs-view-toolbar-inline .previs-view-toolbar-button:hover { background: var(--pd-s3); color: var(--pd-t0); }
+.previs-view-toolbar-inline .previs-view-toolbar-button[aria-pressed="true"] { background: var(--pd-s4); color: var(--pd-t0); }
+.previs-view-toolbar-inline .previs-view-toolbar-button[aria-pressed="true"]::after { display: none; }
+
+/* Header right */
+.previs-desk-header-spacer { flex: 1; }
+.previs-desk-save { color: var(--pd-t2); font-size: var(--pd-ts-sm); white-space: nowrap; }
+.previs-desk-save.is-busy { color: var(--pd-orange); }
+.previs-desk-save.is-danger { color: var(--pd-red); }
+
+.previs-desk-select, .previs-desk-header-select {
+ height: 28px;
+ border: 1px solid var(--pd-b1);
+ border-radius: var(--pd-r-sm);
+ background: var(--pd-s2);
+ color: var(--pd-t0);
+ padding: 0 8px;
+ font-size: var(--pd-ts-sm);
+}
+
+.previs-desk-header-action {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 28px;
+ padding: 0 12px;
+ border: 1px solid var(--pd-b1);
+ border-radius: var(--pd-r-sm);
+ background: var(--pd-s2);
+ color: var(--pd-t0);
+ font-size: var(--pd-ts-sm);
+ font-weight: 500;
+ white-space: nowrap;
+ transition: background 140ms var(--pd-ease-snap),
+ border-color 140ms var(--pd-ease-snap),
+ transform 120ms var(--pd-ease);
+}
+.previs-desk-header-action:hover {
+ background: var(--pd-s3);
+ border-color: var(--pd-b2);
+ transform: translateY(-1px);
+}
+.previs-desk-header-action:active { transform: translateY(0); }
+.previs-desk-header-action.is-primary {
+ background: var(--pd-a0);
+ border-color: var(--pd-a0);
+ color: var(--pd-a-on);
+ font-weight: 600;
+ box-shadow: 0 2px 8px var(--pd-a-bg);
+}
+.previs-desk-header-action.is-primary:hover {
+ background: var(--pd-a1);
+ border-color: var(--pd-a1);
+ box-shadow: 0 4px 16px var(--pd-a-bg);
+}
+.previs-desk-header-action:focus-visible {
+ outline: 2px solid var(--pd-a0);
+ outline-offset: 2px;
+}
+
+/* ── Viewport shell ────────────────────────────────────────────────────────── */
+.previs-desk .previs-viewport-shell,
+.previs-desk .previs-viewport-shell canvas {
+ touch-action: none;
+ cursor: grab;
+}
+.previs-desk .previs-viewport-shell canvas:active { cursor: grabbing; }
+.previs-desk .previs-viewport-shell canvas { user-select: none; }
+
+/* ── Contextual workbench body ─────────────────────────────────────────────── */
+.previs-desk-body {
+ display: grid;
+ grid-template-columns: var(--pd-sidebar) minmax(0, 1fr) var(--pd-inspector);
+ min-height: 0;
+ flex: 1 1 auto;
+}
+
+.previs-desk-sidebar, .previs-desk-inspector {
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ background: var(--pd-s1);
+ scrollbar-width: thin;
+ scrollbar-color: var(--pd-b1) transparent;
+}
+.previs-desk-sidebar { border-right: 1px solid var(--pd-b0); }
+.previs-desk-inspector { border-left: 1px solid var(--pd-b0); }
+
+.previs-desk-sidebar-head, .previs-desk-inspector-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 14px 12px 10px;
+}
+.previs-desk-sidebar-title, .previs-desk-inspector-title {
+ font-size: var(--pd-ts-lg);
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+.previs-desk-sidebar-sub { color: var(--pd-t2); font-size: var(--pd-ts-2xs); margin-top: 2px; }
+
+.previs-desk-search {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 30px;
+ margin: 2px 10px 10px;
+ padding: 0 9px;
+ border: 1px solid var(--pd-b0);
+ border-radius: var(--pd-r-sm);
+ background: var(--pd-s0);
+ color: var(--pd-t2);
+ transition: border-color 150ms var(--pd-ease-snap);
+}
+.previs-desk-search:focus-within { border-color: var(--pd-a0); }
+.previs-desk-search input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--pd-t0); font-size: var(--pd-ts-sm); }
+.previs-desk-search input::placeholder { color: var(--pd-t3); }
+
+/* Section headers */
+.previs-desk-section { border-top: 1px solid var(--pd-b0); }
+.previs-desk-section:first-of-type { border-top: 0; }
+.previs-desk-section-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px 6px;
+ color: var(--pd-t2);
+ font-size: var(--pd-ts-2xs);
+ text-transform: uppercase;
+ letter-spacing: 0.10em;
+ font-weight: 700;
+}
+.previs-desk-section-head button {
+ width: 20px;
+ height: 20px;
+ border: 0;
+ border-radius: var(--pd-r-sm);
+ background: transparent;
+ color: var(--pd-t2);
+ transition: background 120ms, color 120ms;
+}
+.previs-desk-section-head button:hover { background: var(--pd-s3); color: var(--pd-t0); }
+
+/* Entity list rows */
+.previs-desk-list { padding: 0 6px 8px; }
+.previs-desk-row {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ min-height: 34px;
+ width: 100%;
+ padding: 5px 7px;
+ border: 1px solid transparent;
+ border-radius: var(--pd-r-md);
+ background: transparent;
+ text-align: left;
+ color: var(--pd-t0);
+ transition: background 120ms var(--pd-ease-snap),
+ border-color 120ms var(--pd-ease-snap);
+}
+.previs-desk-row:hover { background: var(--pd-s2); }
+.previs-desk-row.is-active {
+ background: var(--pd-a-bg);
+ border-color: var(--pd-a-bd);
+}
+.previs-desk-row-icon { width: 18px; display: grid; place-items: center; color: var(--pd-t2); flex: 0 0 18px; }
+.previs-desk-row-label { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--pd-ts-sm); }
+.previs-desk-row-meta { color: var(--pd-t3); font-size: var(--pd-ts-2xs); }
+.previs-desk-row-delete {
+ opacity: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border: 0;
+ border-radius: var(--pd-r-sm);
+ background: transparent;
+ color: var(--pd-t2);
+ transition: opacity 120ms, color 120ms;
+}
+.previs-desk-row:hover .previs-desk-row-delete,
+.previs-desk-row.is-active .previs-desk-row-delete { opacity: 1; }
+.previs-desk-row-delete:hover { color: var(--pd-red); }
+
+/* Add grid */
+.previs-desk-add-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0,1fr));
+ gap: 4px;
+ padding: 0 8px 12px;
+}
+.previs-desk-add-grid button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 28px;
+ padding: 0 9px;
+ border: 1px solid var(--pd-b0);
+ border-radius: var(--pd-r-sm);
+ background: var(--pd-s0);
+ color: var(--pd-t2);
+ font-size: var(--pd-ts-xs);
+ transition: border-color 120ms, color 120ms, background 120ms;
+}
+.previs-desk-add-grid button:hover { border-color: var(--pd-b2); color: var(--pd-t0); background: var(--pd-s2); }
+
+/* Empty state */
+.previs-desk-empty {
+ margin: 4px 10px 12px;
+ padding: 20px 12px;
+ border: 1px dashed var(--pd-b1);
+ border-radius: var(--pd-r-md);
+ color: var(--pd-t2);
+ text-align: center;
+ font-size: var(--pd-ts-sm);
+ line-height: 1.6;
+}
+
+/* ── Center viewport shell (contextual layout) ──────────────────────────────────── */
+.previs-desk-center {
+ display: flex;
+ min-width: 0;
+ min-height: 0;
+ flex-direction: column;
+ background: var(--pd-s0);
+}
+.previs-desk-center-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-height: 40px;
+ padding: 5px 10px;
+ border-bottom: 1px solid var(--pd-b0);
+ background: var(--pd-s1);
+ box-shadow: inset 0 -1px 0 var(--pd-b0);
+}
+.previs-desk-tool-group { display: flex; align-items: center; gap: 3px; }
+.previs-desk-tool-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 28px;
+ padding: 0 9px;
+ border: 1px solid transparent;
+ border-radius: var(--pd-r-sm);
+ background: transparent;
+ color: var(--pd-t2);
+ font-size: var(--pd-ts-sm);
+ transition: background 120ms var(--pd-ease-snap),
+ color 120ms var(--pd-ease-snap),
+ border-color 120ms var(--pd-ease-snap);
+}
+.previs-desk-tool-button:hover { background: var(--pd-s3); color: var(--pd-t0); }
+.previs-desk-tool-button.is-active { border-color: var(--pd-b1); background: var(--pd-s4); color: var(--pd-t0); }
+.previs-desk-tool-button.is-accent { background: var(--pd-a0); color: var(--pd-s0); border-color: var(--pd-a0); }
+.previs-desk-view-tabs { display: flex; gap: 2px; padding: 2px; border: 1px solid var(--pd-b0); border-radius: var(--pd-r-sm); background: var(--pd-s0); }
+.previs-desk-view-tabs button { height: 24px; padding: 0 10px; border: 0; border-radius: calc(var(--pd-r-sm) - 1px); background: transparent; color: var(--pd-t2); font-size: var(--pd-ts-xs); transition: background 120ms, color 120ms; }
+.previs-desk-view-tabs button.is-active { background: var(--pd-s4); color: var(--pd-t0); }
+
+/* Viewport grid for contextual layout */
+.previs-desk-viewport-grid { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 1px; min-height: 0; flex: 1 1 auto; background: var(--pd-b0); }
+.previs-desk-viewport-panel { position: relative; min-width: 0; min-height: 260px; overflow: hidden; background: var(--pd-s0); }
+.previs-desk-viewport-panel .previs-viewport-shell { position: absolute; inset: 0; }
+
+/* Panel overlay bar */
+.previs-desk-panel-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: absolute;
+ top: 0; left: 0; right: 0;
+ z-index: 5;
+ min-height: 28px;
+ padding: 0 10px;
+ background: oklch(0.10 0.016 240 / 0.88);
+ backdrop-filter: blur(8px);
+ color: var(--pd-t1);
+ font-size: var(--pd-ts-xs);
+}
+.previs-desk-panel-bar strong { color: var(--pd-t0); font-weight: 600; }
+.previs-desk-panel-bar span { display: inline-flex; align-items: center; gap: 6px; }
+.previs-desk-panel-bar-center { position: absolute; left: 50%; transform: translateX(-50%); }
+.previs-desk-panel-bar .pd-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--pd-green); }
+.previs-desk-shot-placeholder { position: absolute; inset: 28px 0 0; display: grid; place-items: center; pointer-events: none; color: oklch(1 0 0 / 0.35); font-size: var(--pd-ts-sm); }
+
+/* Monitor strip */
+.previs-desk-monitor-strip {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 36px;
+ padding: 4px 10px;
+ border-top: 1px solid var(--pd-b0);
+ background: var(--pd-s1);
+}
+.previs-desk-monitor-strip-label { margin-right: 4px; color: var(--pd-t2); font-size: var(--pd-ts-xs); }
+.previs-desk-monitor-strip button {
+ height: 26px;
+ padding: 0 9px;
+ border: 1px solid var(--pd-b0);
+ border-radius: var(--pd-r-sm);
+ background: var(--pd-s2);
+ color: var(--pd-t2);
+ font-size: var(--pd-ts-xs);
+ transition: color 120ms, border-color 120ms, background 120ms;
+}
+.previs-desk-monitor-strip button:hover { color: var(--pd-t0); border-color: var(--pd-b2); background: var(--pd-s3); }
+.previs-desk-monitor-strip .pd-monitor-spacer { flex: 1; }
+
+/* ── Inspector (contextual layout) ──────────────────────────────────────────────── */
+.previs-desk-inspector-head { border-bottom: 1px solid var(--pd-b0); }
+.previs-desk-inspector-tabs { display: flex; gap: 2px; padding: 6px 10px; border-bottom: 1px solid var(--pd-b0); }
+.previs-desk-inspector-tabs button {
+ flex: 1;
+ height: 28px;
+ border: 1px solid transparent;
+ border-radius: var(--pd-r-sm);
+ background: transparent;
+ color: var(--pd-t2);
+ font-size: var(--pd-ts-sm);
+ transition: background 120ms, color 120ms, border-color 120ms;
+}
+.previs-desk-inspector-tabs button.is-active { background: var(--pd-s4); color: var(--pd-t0); border-color: var(--pd-b1); }
+.previs-desk-inspector-content { min-height: 0; padding: 10px 10px 18px; display: flex; flex-direction: column; gap: 12px; }
+.previs-desk-inspector-content .previs-inspector { padding: 0; }
+.previs-desk-inspector-content .previs-inspector-title { color: var(--pd-t0); }
+.previs-desk-inspector-content .ant-input,
+.previs-desk-inspector-content .ant-input-number,
+.previs-desk-inspector-content select,
+.previs-desk-inspector-content input { background: var(--pd-s0) !important; border-color: var(--pd-b1) !important; color: var(--pd-t0) !important; }
+.previs-desk-inspector-content .ant-btn { background: var(--pd-s2); border-color: var(--pd-b1); color: var(--pd-t0); }
+.previs-desk-inspector-content .ant-btn-primary { background: var(--pd-a0); border-color: var(--pd-a0); color: var(--pd-s0); }
+.previs-desk-inspector-content .previs-inspector-section-title { color: var(--pd-t1); }
+.previs-desk-inspector-content .previs-field-label,
+.previs-desk-inspector-content label { color: var(--pd-t1); }
+.previs-desk-inspector-footer { padding: 10px; border-top: 1px solid var(--pd-b0); }
+.previs-desk-ai-note { padding: 10px; border: 1px solid var(--pd-b1); border-radius: var(--pd-r-md); background: var(--pd-s0); color: var(--pd-t1); line-height: 1.55; font-size: var(--pd-ts-sm); }
+.previs-desk-ai-note strong { display: block; margin-bottom: 4px; color: var(--pd-t0); }
+
+/* Asset panel */
+.previs-desk-assets-panel, .previs-desk-prompt-panel { margin-top: 12px; padding: 10px; border: 1px solid var(--pd-b0); border-radius: var(--pd-r-md); background: var(--pd-s0); }
+.previs-desk-assets-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--pd-t0); }
+.previs-desk-assets-head > div { min-width: 0; }
+.previs-desk-assets-head strong { display: block; font-size: var(--pd-ts-sm); }
+.previs-desk-assets-head span { display: block; margin-top: 3px; color: var(--pd-t1); font-size: var(--pd-ts-xs); }
+.previs-desk-assets-list { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
+.previs-desk-asset-row { display: flex; align-items: center; gap: 5px; min-width: 0; }
+.previs-desk-asset-main { display: flex; min-width: 0; flex: 1; align-items: center; gap: 7px; padding: 5px; border: 1px solid transparent; border-radius: var(--pd-r-sm); background: transparent; color: var(--pd-t0); text-align: left; transition: background 120ms, border-color 120ms; }
+.previs-desk-asset-main:hover { background: var(--pd-s3); border-color: var(--pd-b0); }
+.previs-desk-asset-main img { width: 28px; height: 24px; flex: 0 0 28px; border-radius: 3px; object-fit: cover; background: var(--pd-s2); }
+.previs-desk-asset-main span { min-width: 0; }
+.previs-desk-asset-main strong, .previs-desk-asset-main small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.previs-desk-asset-main strong { font-size: var(--pd-ts-xs); }
+.previs-desk-asset-main small { margin-top: 2px; color: var(--pd-t2); font-size: var(--pd-ts-2xs); }
+.previs-desk-asset-actions { display: flex; align-items: center; gap: 2px; }
+.previs-desk-asset-actions button, .previs-desk-assets-footer button { display: inline-flex; align-items: center; justify-content: center; min-width: 26px; height: 24px; padding: 0 5px; border: 1px solid var(--pd-b0); border-radius: var(--pd-r-sm); background: transparent; color: var(--pd-t1); font-size: var(--pd-ts-2xs); transition: border-color 120ms, color 120ms; }
+.previs-desk-asset-actions button:hover, .previs-desk-assets-footer button:hover { border-color: var(--pd-a0); color: var(--pd-t0); }
+.previs-desk-assets-footer { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-top: 8px; color: var(--pd-t1); font-size: var(--pd-ts-2xs); line-height: 1.4; }
+.previs-desk-prompt-panel textarea { width: 100%; min-height: 115px; margin-top: 8px; resize: vertical; border: 1px solid var(--pd-b0); border-radius: var(--pd-r-sm); background: var(--pd-s0); color: var(--pd-t0); padding: 7px; font-size: var(--pd-ts-2xs); line-height: 1.45; outline: none; transition: border-color 150ms; }
+.previs-desk-prompt-panel textarea:focus { border-color: var(--pd-a0); }
+.previs-desk-prompt-panel p { margin: 7px 0 0; color: var(--pd-t1); font-size: var(--pd-ts-2xs); line-height: 1.45; }
+
+/* ── Timeline (contextual layout) ───────────────────────────────────────────────── */
+.previs-desk-timeline { flex: 0 0 var(--pd-timeline); min-height: 180px; border-top: 1px solid var(--pd-b1); background: var(--pd-s1); }
+.previs-desk-timeline .previs-sequencer { height: 100% !important; border: 0 !important; background: transparent !important; }
+.previs-desk-timeline .previs-sequencer-resizer { display: none; }
+.previs-desk-timeline .previs-sequencer > header { background: var(--pd-s2); border-bottom-color: var(--pd-b0); }
+.previs-desk-timeline .previs-sequencer-body { background: var(--pd-s1); }
+.previs-desk-timeline .previs-sequencer-label { background: var(--pd-s2); color: var(--pd-t1); }
+.previs-desk-timeline .previs-sequencer-row.is-selected .previs-sequencer-label { color: var(--pd-t0); background: var(--pd-a-bg); }
+.previs-desk-timeline .previs-sequencer-track { background: var(--pd-s1); }
+.previs-desk-timeline .previs-sequencer-ruler { background: var(--pd-s2); }
+
+/* ── Output step cards ───────────────────────────────────────────────────── */
+.previs-output-step-output-panel { color: var(--pd-t0); }
+.previs-output-step-step-card { display: flex; flex-direction: column; gap: 10px; margin-bottom: 10px; padding: 12px; border: 1px solid var(--pd-b0); border-radius: var(--pd-r-md); background: var(--pd-s1); }
+.previs-output-step-step-title { display: flex; align-items: flex-start; gap: 9px; }
+.previs-output-step-step-title strong { display: block; color: var(--pd-t0); font-size: var(--pd-ts-sm); }
+.previs-output-step-step-title p { margin: 3px 0 0; color: var(--pd-t1); font-size: var(--pd-ts-xs); line-height: 1.5; }
+.previs-output-step-step-number { display: grid; width: 22px; height: 22px; flex: 0 0 22px; place-items: center; border-radius: 7px; background: var(--pd-a-bg); color: var(--pd-a0); font-size: var(--pd-ts-xs); font-weight: 700; }
+.previs-output-step-field { display: flex; flex-direction: column; gap: 4px; color: var(--pd-t1); font-size: var(--pd-ts-xs); }
+.previs-output-step-field input, .previs-output-step-field select { width: 100%; height: 28px; border: 1px solid var(--pd-b0); border-radius: var(--pd-r-sm); background: var(--pd-s0); color: var(--pd-t0); padding: 0 7px; outline: none; transition: border-color 150ms; }
+.previs-output-step-field input:focus, .previs-output-step-field select:focus { border-color: var(--pd-a0); box-shadow: 0 0 0 3px var(--pd-a-bg); }
+.previs-output-step-help { margin: -2px 0 0; color: var(--pd-t1); font-size: var(--pd-ts-xs); line-height: 1.55; }
+.previs-output-step-output-card { border-color: oklch(0.75 0.14 155 / 0.28); background: linear-gradient(180deg, oklch(0.75 0.14 155 / 0.06), var(--pd-s1)); }
+.previs-output-step-output-status { display: flex; align-items: flex-start; gap: 7px; color: var(--pd-t1); font-size: var(--pd-ts-xs); line-height: 1.45; }
+.previs-output-step-output-status.is-ready { color: var(--pd-green); }
+.previs-output-step-output-dot { width: 5px; height: 5px; flex: 0 0 5px; margin-top: 5px; border-radius: 999px; background: currentColor; }
+.previs-output-step-output-note { display: flex; flex-direction: column; gap: 4px; margin-top: 3px; padding: 2px 2px 14px; color: var(--pd-t1); font-size: var(--pd-ts-xs); line-height: 1.55; }
+.previs-output-step-output-note strong { color: var(--pd-t0); font-size: var(--pd-ts-sm); }
+
+/* ── Viewport navigation overlay ────────────────────────────────────────────── */
+.previs-viewport-navigation {
+ position: absolute; right: 12px; bottom: 12px; z-index: 5;
+ display: inline-flex; align-items: center; gap: 2px;
+ padding: 3px;
+ border: 1px solid oklch(1 0 0 / 0.12);
+ border-radius: var(--pd-r-sm);
+ background: oklch(0.10 0.016 240 / 0.80);
+ backdrop-filter: blur(12px);
+ box-shadow: var(--pd-shadow-raised);
+}
+.previs-viewport-navigation button {
+ min-width: 28px; height: 24px; padding: 0 7px;
+ border: 0; border-radius: calc(var(--pd-r-sm) - 1px);
+ color: oklch(1 0 0 / 0.70); background: transparent;
+ font-size: var(--pd-ts-2xs);
+ transition: background 120ms var(--pd-ease-snap), color 120ms var(--pd-ease-snap);
+}
+.previs-viewport-navigation button:hover:not(:disabled),
+.previs-viewport-navigation button:focus-visible:not(:disabled) {
+ color: var(--pd-t0); background: oklch(1 0 0 / 0.12); outline: none;
+}
+.previs-viewport-navigation button:disabled { opacity: 0.32; }
+.previs-viewport-navigation-divider { width: 1px; height: 14px; background: oklch(1 0 0 / 0.14); }
+
+/* ── Dropdown overlay (add menus) ────────────────────────────────────────────── */
+.previs-add-menu-overlay { min-width: 200px; }
+.previs-add-menu-overlay .ant-dropdown-menu {
+ background: var(--pd-s5);
+ border: 1px solid var(--pd-b1);
+ border-radius: var(--pd-r-lg);
+ padding: 4px;
+ box-shadow: var(--pd-shadow-floating);
+}
+.previs-add-menu-overlay .ant-dropdown-menu-item {
+ border-radius: var(--pd-r-sm);
+ color: var(--pd-t1);
+ font-size: var(--pd-ts-sm);
+ padding: 7px 12px;
+ gap: 8px;
+ transition: background 100ms var(--pd-ease-snap), color 100ms var(--pd-ease-snap);
+}
+.previs-add-menu-overlay .ant-dropdown-menu-item:hover,
+.previs-add-menu-overlay .ant-dropdown-menu-item-active { background: var(--pd-s3); color: var(--pd-t0); }
+.previs-add-menu-overlay .ant-dropdown-menu-item-divider { background: var(--pd-b0); margin: 3px 0; }
+.previs-add-menu-overlay .ant-dropdown-menu-submenu-title {
+ border-radius: var(--pd-r-sm); color: var(--pd-t1); font-size: var(--pd-ts-sm); padding: 7px 12px;
+ transition: background 100ms;
+}
+.previs-add-menu-overlay .ant-dropdown-menu-submenu-title:hover { background: var(--pd-s3); color: var(--pd-t0); }
+.previs-add-menu-overlay .ant-dropdown-menu-submenu-arrow { color: var(--pd-t2); }
+
+/* ── Responsive ──────────────────────────────────────────────────────────────── */
+@media (max-width: 1100px) {
+ [data-canvas-previs-workbench].previs-desk { --pd-sidebar: 204px; --pd-inspector: 288px; }
+ .previs-desk-brand { min-width: 140px; }
+ .previs-desk-brand-sub { display: none; }
+ .previs-desk-scene-title { width: 148px; }
+}
+@media (max-width: 820px) {
+ [data-canvas-previs-workbench].previs-desk { --pd-sidebar: 188px; --pd-inspector: 0px; }
+ .previs-desk-inspector { display: none; }
+ .previs-desk-viewport-grid { grid-template-columns: 1fr; }
+ .previs-desk-viewport-panel:nth-child(2) { display: none; }
+ .previs-desk-header-select, .previs-desk-header-action:not(.is-primary), .previs-desk-save { display: none; }
+}
+
+/* 画布预演节点:以镜头卡为主视觉,保留足够的俯视画面和清晰的状态层级。 */
+
+.previs-desk-timeline { flex: 0 0 var(--pd-timeline); min-height: 180px; border-top: 1px solid var(--pd-line-strong); background: #121416; }
+.previs-desk-timeline .previs-sequencer { height: 100% !important; border: 0 !important; background: transparent !important; }
+.previs-desk-timeline .previs-sequencer-resizer { display: none; }
+.previs-desk-timeline .previs-sequencer > header { background: #17191c; border-bottom-color: var(--pd-line); }
+.previs-desk-timeline .previs-sequencer-body { background: #111315; }
+.previs-desk-timeline .previs-sequencer-label { background: #17191c; color: var(--pd-muted); }
+.previs-desk-timeline .previs-sequencer-row.is-selected .previs-sequencer-label { color: #fff; background: rgba(109,92,255,.2); }
+.previs-desk-timeline .previs-sequencer-track { background: #101214; }
+.previs-desk-timeline .previs-sequencer-ruler { background: #17191c; }
+
+.previs-output-step-output-panel { color: var(--pd-text); }
+.previs-output-step-step-card { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; padding: 12px; border: 1px solid var(--pd-line); border-radius: 10px; background: rgba(255,255,255,.025); }
+.previs-output-step-step-title { display: flex; align-items: flex-start; gap: 9px; }
+.previs-output-step-step-title strong { display: block; color: var(--pd-text); font-size: 12px; }
+.previs-output-step-step-title p { margin: 3px 0 0; color: var(--pd-muted); font-size: 10px; line-height: 1.5; }
+.previs-output-step-step-number { display: grid; width: 22px; height: 22px; flex: 0 0 22px; place-items: center; border-radius: 7px; background: rgba(155,188,255,.14); color: var(--pd-accent-2); font-size: 11px; font-weight: 700; }
+.previs-output-step-field { display: flex; flex-direction: column; gap: 4px; color: var(--pd-muted); font-size: 10px; }
+.previs-output-step-field input, .previs-output-step-field select { width: 100%; height: 29px; border: 1px solid var(--pd-line); border-radius: 5px; background: #101113; color: var(--pd-text); padding: 0 7px; outline: none; }
+.previs-output-step-field input:focus, .previs-output-step-field select:focus { border-color: var(--pd-accent); box-shadow: 0 0 0 2px rgba(155,188,255,.14); }
+.previs-output-step-help { margin: -2px 0 0; color: var(--pd-muted); font-size: 10px; line-height: 1.55; }
+.previs-output-step-output-card { border-color: rgba(101,215,162,.25); background: linear-gradient(180deg, rgba(101,215,162,.06), rgba(255,255,255,.025)); }
+.previs-output-step-output-status { display: flex; align-items: flex-start; gap: 7px; color: var(--pd-muted); font-size: 10px; line-height: 1.45; }
+.previs-output-step-output-status.is-ready { color: var(--pd-green); }
+.previs-output-step-output-dot { width: 6px; height: 6px; flex: 0 0 6px; margin-top: 4px; border-radius: 999px; background: currentColor; }
+.previs-output-step-output-note { display: flex; flex-direction: column; gap: 4px; margin-top: 3px; padding: 2px 2px 16px; color: var(--pd-muted); font-size: 10px; line-height: 1.55; }
+.previs-output-step-output-note strong { color: var(--pd-text); font-size: 11px; }
+
+@media (max-width: 1100px) {
+ [data-canvas-previs-workbench].previs-desk { --pd-sidebar: 204px; --pd-inspector: 286px; }
+ .previs-desk-brand { min-width: 142px; }
+ .previs-desk-brand-sub { display: none; }
+ .previs-desk-scene-title { width: 140px; }
+}
+@media (max-width: 820px) {
+ [data-canvas-previs-workbench].previs-desk { --pd-sidebar: 190px; --pd-inspector: 0px; }
+ .previs-desk-inspector { display: none; }
+ .previs-desk-viewport-grid { grid-template-columns: 1fr; }
+ .previs-desk-viewport-panel:nth-child(2) { display: none; }
+ .previs-desk-header-select, .previs-desk-header-action:not(.is-primary), .previs-desk-save { display: none; }
+}
+
+.previs-viewport-navigation { position: absolute; right: 12px; bottom: 12px; z-index: 5; display: inline-flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid rgba(255,255,255,.14); border-radius: 10px; background: rgba(20,25,31,.78); backdrop-filter: blur(14px); box-shadow: 0 8px 24px rgba(0,0,0,.18); }
+.previs-viewport-navigation button { min-width: 30px; height: 26px; padding: 0 7px; border: 0; border-radius: 6px; color: rgba(255,255,255,.76); background: transparent; font-size: 10px; transition: background .15s ease, color .15s ease; }
+.previs-viewport-navigation button:hover:not(:disabled), .previs-viewport-navigation button:focus-visible:not(:disabled) { color: #fff; background: rgba(255,255,255,.12); outline: none; }
+.previs-viewport-navigation button:disabled { opacity: .35; }
+.previs-viewport-navigation-divider { width: 1px; height: 16px; background: rgba(255,255,255,.16); }
+
+/* ── 添加角色 / 添加机位 dropdown overlay ─────────────────────────────────── */
+.previs-add-menu-overlay { min-width: 200px; }
+.previs-add-menu-overlay .ant-dropdown-menu { background: #1a1e26; border: 1px solid rgba(255,255,255,.12); border-radius: 10px; padding: 4px; box-shadow: 0 16px 40px rgba(0,0,0,.52), 0 2px 8px rgba(0,0,0,.28); }
+.previs-add-menu-overlay .ant-dropdown-menu-item { border-radius: 6px; color: rgba(255,255,255,.82); font-size: 12px; padding: 7px 12px; gap: 8px; transition: background .12s ease, color .12s ease; }
+.previs-add-menu-overlay .ant-dropdown-menu-item:hover, .previs-add-menu-overlay .ant-dropdown-menu-item-active { background: rgba(255,255,255,.08); color: #fff; }
+.previs-add-menu-overlay .ant-dropdown-menu-item-divider { background: rgba(255,255,255,.1); margin: 3px 0; }
+.previs-add-menu-overlay .ant-dropdown-menu-submenu-title { border-radius: 6px; color: rgba(255,255,255,.82); font-size: 12px; padding: 7px 12px; transition: background .12s ease; }
+.previs-add-menu-overlay .ant-dropdown-menu-submenu-title:hover { background: rgba(255,255,255,.08); color: #fff; }
+.previs-add-menu-overlay .ant-dropdown-menu-submenu-arrow { color: rgba(255,255,255,.38); }
+
+/* ═══════════════════════════════════════════════════════════════════════════
+ Previs workbench component system — pv-* component system
+ Design tokens re-use --pd-* already defined on [data-canvas-previs-workbench]
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+/* Layout: three columns, full height */
+.pv-layout {
+ display: grid;
+ grid-template-columns: minmax(260px, 300px) minmax(0, 1fr) minmax(320px, 360px);
+ width: 100%;
+ min-height: 0;
+ min-width: 0;
+ overflow: hidden;
+ flex: 1 1 auto;
+}
+
+/* 侧栏可收起:视口占满,工具条居中保持在画面上。 */
+.pv-layout { transition: grid-template-columns 220ms var(--pd-ease); }
+.pv-layout.is-scene-collapsed { grid-template-columns: 0 minmax(0, 1fr) minmax(320px, 360px); }
+.pv-layout.is-inspector-collapsed { grid-template-columns: minmax(260px, 300px) minmax(0, 1fr) 0; }
+.pv-layout.is-scene-collapsed.is-inspector-collapsed { grid-template-columns: 0 minmax(0, 1fr) 0; }
+.pv-layout.is-scene-collapsed > .pv-panel--left,
+.pv-layout.is-inspector-collapsed > .pv-panel--right { visibility: hidden; border: 0; }
+@media (prefers-reduced-motion: reduce) { .pv-layout { transition: none; } }
+
+/* ── Panels ─────────────────────────────────────────────────────────────── */
+.pv-panel {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ background: var(--pd-surface);
+}
+.pv-panel--left { border-right: 1px solid var(--pd-line); }
+.pv-panel--right { border-left: 1px solid var(--pd-line); }
+.pv-panel-close { display: none; }
+
+/* ── Panel overlay affordances ──────────────────────────────────────────── */
+
+/* ── Panel header ───────────────────────────────────────────────────────── */
+.pv-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 10px 12px 8px;
+ border-bottom: 1px solid var(--pd-line);
+ flex: 0 0 auto;
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: var(--pd-surface);
+}
+.pv-panel-header__title { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
+.pv-panel-header__label { font-size: 13px; font-weight: 700; color: var(--pd-text); }
+.pv-panel-header__count { font-size: 10px; font-weight: 600; color: var(--pd-muted); background: var(--pd-surface-2); border-radius: 8px; padding: 1px 6px; }
+.pv-panel-header__sub { font-size: 10px; color: var(--pd-muted); }
+.pv-panel-header__actions { display: flex; gap: 4px; flex: 0 0 auto; }
+.pv-scene-search {
+ display: flex; align-items: center; gap: 6px; height: 30px; margin: 8px 10px 6px; padding: 0 8px;
+ border: 1px solid var(--pd-line); border-radius: 7px; background: color-mix(in srgb, var(--pd-surface-2) 78%, transparent); color: var(--pd-muted);
+}
+.pv-scene-search:focus-within { border-color: var(--pd-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--pd-accent) 18%, transparent); }
+.pv-scene-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--pd-text); font-size: 11px; }
+.pv-scene-search input::placeholder { color: var(--pd-faint); }
+.pv-scene-search button { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border: 0; border-radius: 4px; background: transparent; color: var(--pd-muted); }
+.pv-scene-search button:hover { color: var(--pd-text); background: var(--pd-surface-3); }
+.pv-icon-btn {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 28px; height: 28px; border: 1px solid var(--pd-line); border-radius: 6px;
+ background: var(--pd-surface-2); color: var(--pd-muted); cursor: pointer;
+ transition: border-color .12s, color .12s, background .12s;
+}
+.pv-icon-btn:hover { border-color: var(--pd-accent); color: var(--pd-text); background: var(--pd-surface-3); }
+.pv-icon-btn:focus-visible { outline: 2px solid var(--pd-accent); outline-offset: 2px; }
+
+/* ── Entity sections ────────────────────────────────────────────────────── */
+.pv-entity-section { border-top: 1px solid var(--pd-line); padding: 6px 0 2px; }
+.pv-entity-section:first-of-type { border-top: none; }
+.pv-entity-section__label {
+ display: flex; align-items: center; gap: 5px;
+ padding: 4px 12px 5px;
+ font-size: 10px; font-weight: 600;
+ text-transform: uppercase; letter-spacing: .08em;
+ color: var(--pd-muted);
+}
+.pv-entity-section__label span {
+ font-size: 10px; font-weight: 600; color: var(--pd-faint);
+ background: var(--pd-surface-2); border-radius: 6px; padding: 1px 5px;
+}
+.pv-entity-section__add-btn {
+ display: inline-flex; align-items: center; gap: 4px;
+ padding: 3px 8px; border-radius: 5px; border: 0;
+ background: rgba(155,188,255,.12); color: var(--pd-accent);
+ font-size: 10px; font-weight: 600; cursor: pointer;
+ transition: background .12s, color .12s;
+}
+.pv-entity-section__add-btn:hover { background: rgba(155,188,255,.22); color: #fff; }
+.pv-entity-section__count { color: var(--pd-muted); font-size: 10px; font-weight: 400; }
+
+.pv-entity-section__add {
+ margin-left: auto; display: inline-flex; align-items: center; justify-content: center;
+ width: 18px; height: 18px; border: 0; border-radius: 4px; background: transparent;
+ color: var(--pd-muted); cursor: pointer;
+}
+.pv-entity-section__add:hover { background: var(--pd-surface-3); color: var(--pd-text); }
+
+/* ── Entity rows ────────────────────────────────────────────────────────── */
+.pv-entity-row {
+ display: flex; flex-direction: column;
+ margin: 0 6px 1px; border: 1px solid transparent; border-radius: 7px;
+ transition: background .1s, border-color .1s;
+}
+.pv-entity-row:hover { background: var(--pd-surface-2); }
+.pv-entity-row--active {
+ background: rgba(100,130,255,.1);
+ border-color: rgba(140,165,255,.35);
+}
+.pv-entity-row__main {
+ display: flex; align-items: center; gap: 8px;
+ width: 100%; min-height: 34px; padding: 5px 8px;
+ border: 0; background: transparent; color: var(--pd-text);
+ text-align: left; cursor: pointer;
+}
+.pv-entity-row__avatar {
+ display: grid; place-items: center;
+ width: 22px; height: 22px; flex: 0 0 22px;
+ border-radius: 50%; color: #fff;
+}
+.pv-entity-row__icon { display: grid; place-items: center; width: 22px; flex: 0 0 22px; color: var(--pd-muted); }
+.pv-entity-row__color-dot { width: 10px; height: 10px; flex: 0 0 10px; border-radius: 50%; }
+.pv-entity-row__info { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
+.pv-entity-row__name { font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pv-entity-row__meta { font-size: 10px; color: var(--pd-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pv-entity-row__badge {
+ font-size: 9px; font-weight: 700; letter-spacing: .06em;
+ color: var(--pd-accent); background: rgba(155,188,255,.14);
+ border-radius: 4px; padding: 1px 5px; flex: 0 0 auto;
+}
+.pv-entity-row__del {
+ opacity: .45; display: inline-flex; align-items: center; justify-content: center;
+ width: 22px; height: 22px; border: 0; border-radius: 4px;
+ background: transparent; color: var(--pd-muted); cursor: pointer;
+ position: absolute; right: 8px;
+ transition: opacity .1s, color .1s;
+}
+.pv-entity-row { position: relative; }
+.pv-entity-row:hover .pv-entity-row__del,
+.pv-entity-row--active .pv-entity-row__del { opacity: 1; }
+.pv-entity-row__del:hover { color: #ff8c8c; }
+
+.previs-workbench-tooltip-anchor { position: relative; display: inline-flex; }
+.previs-workbench-tooltip {
+ position: absolute; left: 50%; top: calc(100% + 6px); z-index: 40;
+ transform: translateX(-50%); pointer-events: none; white-space: nowrap;
+ border: 1px solid var(--pd-b1); border-radius: 5px; padding: 4px 7px;
+ background: var(--pd-s5); color: var(--pd-t0); font-size: 10px; line-height: 1.2;
+ box-shadow: 0 8px 20px rgba(0,0,0,.28);
+}
+
+/* Inline actor controls (shown when selected) */
+.pv-entity-row__inline-controls { padding: 4px 8px 8px; display: flex; flex-direction: column; gap: 7px; }
+.pv-entity-row__footer { display: flex; gap: 6px; }
+
+/* ── Pose grid ──────────────────────────────────────────────────────────── */
+.pv-pose-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 3px; }
+.pv-pose-chip {
+ padding: 4px 2px; border: 1px solid var(--pd-line); border-radius: 5px;
+ background: rgba(255,255,255,.04); color: var(--pd-muted);
+ font-size: 10px; cursor: pointer;
+ transition: border-color .1s, color .1s, background .1s;
+}
+.pv-pose-chip:hover { border-color: var(--pd-line-strong); color: var(--pd-text); }
+.pv-pose-chip--active { border-color: var(--pd-accent); background: rgba(155,188,255,.12); color: var(--pd-accent); }
+
+/* ── Color strip ────────────────────────────────────────────────────────── */
+.pv-color-strip { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
+.pv-color-dot {
+ width: 18px; height: 18px; border-radius: 50%; border: 2px solid transparent;
+ cursor: pointer; transition: transform .1s, border-color .1s;
+}
+.pv-color-dot:hover { transform: scale(1.15); }
+.pv-color-dot--active { border-color: #fff; box-shadow: 0 0 0 1px rgba(255,255,255,.4); }
+.pv-color-custom { display: inline-flex; cursor: pointer; }
+.pv-color-custom__swatch { width: 18px; height: 18px; border-radius: 50%; border: 2px dashed rgba(255,255,255,.4); }
+
+/* ── Buttons ────────────────────────────────────────────────────────────── */
+.pv-btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: 4px;
+ border-radius: 5px; border: 1px solid transparent; cursor: pointer;
+ font-size: 11px; font-weight: 500; transition: background .1s, border-color .1s, color .1s;
+ white-space: nowrap;
+}
+.pv-btn--xs { height: 24px; padding: 0 8px; }
+.pv-btn--sm { height: 28px; padding: 0 11px; }
+.pv-btn--ghost { background: transparent; border-color: var(--pd-line); color: var(--pd-muted); }
+.pv-btn--ghost:hover { border-color: var(--pd-line-strong); color: var(--pd-text); background: var(--pd-surface-3); }
+.pv-btn--accent { background: var(--pd-a0); border-color: var(--pd-a0); color: var(--pd-a-on); }
+.pv-btn--accent:hover { background: var(--pd-a1); border-color: var(--pd-a1); }
+.pv-btn--danger:hover { border-color: rgba(255,120,120,.5); color: #ff8c8c; }
+
+/* ── Empty hint ─────────────────────────────────────────────────────────── */
+.pv-empty-hint {
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
+ margin: 16px 12px; padding: 18px;
+ border: 1px dashed var(--pd-line); border-radius: 8px;
+ color: var(--pd-muted); font-size: 12px; text-align: center;
+}
+
+/* ── Prop library ───────────────────────────────────────────────────────── */
+.pv-entity-section--prop-lib { padding-bottom: 6px; }
+.pv-prop-cats { display: flex; flex-direction: column; gap: 6px; padding: 0 8px 4px; }
+.pv-prop-cat__label { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; color: var(--pd-faint); margin-bottom: 3px; }
+.pv-prop-cat__items { display: flex; flex-wrap: wrap; gap: 3px; }
+.pv-prop-chip {
+ display: flex; flex-direction: column; align-items: flex-start; gap: 1px;
+ padding: 4px 7px; border: 1px solid var(--pd-line); border-radius: 5px;
+ background: rgba(255,255,255,.03); cursor: pointer;
+ transition: border-color .1s, background .1s;
+}
+.pv-prop-chip:hover { border-color: var(--pd-line-strong); background: var(--pd-surface-2); }
+.pv-prop-chip__name { font-size: 11px; color: var(--pd-text); }
+.pv-prop-chip__size { font-size: 9px; color: var(--pd-faint); }
+
+/* ── Background images ──────────────────────────────────────────────────── */
+.pv-bg-list { display: flex; flex-direction: column; gap: 3px; padding: 0 8px 4px; }
+.pv-bg-row { display: flex; align-items: center; gap: 5px; }
+.pv-bg-row__thumb {
+ display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0;
+ padding: 4px 6px; border: 1px solid var(--pd-line); border-radius: 5px;
+ background: rgba(255,255,255,.03); cursor: pointer;
+ transition: border-color .1s;
+}
+.pv-bg-row__thumb:hover { border-color: var(--pd-line-strong); }
+.pv-bg-row__img { width: 32px; height: 24px; object-fit: cover; border-radius: 3px; flex: 0 0 32px; background: #222; }
+.pv-bg-row__name { font-size: 10px; color: var(--pd-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pv-bg-row__btns { display: flex; gap: 3px; flex: 0 0 auto; }
+.pv-bg-btn {
+ height: 24px; padding: 0 6px; border: 1px solid var(--pd-line); border-radius: 4px;
+ background: transparent; color: var(--pd-muted); font-size: 9px; cursor: pointer;
+ display: inline-flex; align-items: center;
+ transition: border-color .1s, color .1s;
+}
+.pv-bg-btn:hover { border-color: var(--pd-accent); color: var(--pd-text); }
+.pv-bg-btn--active { border-color: var(--pd-accent); color: var(--pd-accent); }
+.pv-bg-btn--danger:hover { border-color: rgba(255,120,120,.5); color: #ff8c8c; }
+.pv-panorama-badge {
+ display: flex; align-items: center; justify-content: space-between;
+ margin: 4px 8px; padding: 5px 9px;
+ border-radius: 5px; background: rgba(240,181,106,.1);
+ color: var(--pd-orange); font-size: 10px;
+}
+.pv-panorama-badge button { border: 0; background: transparent; color: rgba(255,255,255,.55); cursor: pointer; font-size: 10px; }
+.pv-panorama-badge button:hover { color: #fff; }
+
+/* ── Viewport area: single view ───────────────────────────────────────────── */
+.pv-viewport-area {
+ position: relative;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--pd-s0);
+}
+.pv-viewport-pane {
+ position: relative; min-width: 0; min-height: 0; overflow: hidden;
+ background: var(--pd-s0);
+}
+.pv-viewport-pane .previs-viewport-shell,
+.pv-viewport-pane .previs-viewport-shell canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
+
+/* ── Prop grid upload row ────────────────────────────────────────────────── */
+.pv-prop-upload-row {
+ padding: 6px 8px 2px;
+}
+.pv-prop-upload-full {
+ display: flex; align-items: center; justify-content: center; gap: 6px;
+ width: 100%; padding: 7px 0;
+ border: 1px dashed var(--pd-line); border-radius: 6px;
+ background: transparent; color: var(--pd-muted);
+ font-size: 11px; cursor: pointer;
+ transition: border-color .12s, color .12s, background .12s;
+}
+.pv-prop-upload-full:hover {
+ border-color: var(--pd-accent); color: var(--pd-accent);
+ background: rgba(var(--pd-accent-rgb, 100 140 255) / .06);
+}
+
+.pv-pane-label {
+ position: absolute; top: 8px; left: 8px; z-index: 4;
+ display: inline-flex; align-items: center; gap: 5px;
+ padding: 3px 9px; border-radius: 5px;
+ background: rgba(13,15,17,.72); backdrop-filter: blur(10px);
+ color: rgba(255,255,255,.7); font-size: 10px; font-weight: 600;
+ letter-spacing: .04em; pointer-events: none;
+}
+.pv-pane-label--cam { color: var(--pd-accent); }
+.pv-pane-label__meta { color: rgba(255,255,255,.4); font-weight: 400; }
+.pv-pane-hint {
+ position: absolute; bottom: 12px; left: 50%; z-index: 4;
+ transform: translateX(-50%);
+ display: inline-flex; align-items: center; gap: 6px;
+ padding: 6px 14px; border-radius: var(--pd-r-md);
+ background: var(--pd-s5); border: 1px solid var(--pd-b1);
+ box-shadow: var(--pd-shadow-raised);
+ color: var(--pd-t1); font-size: var(--pd-ts-xs); font-weight: 500; white-space: nowrap;
+ pointer-events: none;
+}
+.pv-trajectory-guide {
+ position: absolute; top: 54px; left: 50%; z-index: 5; transform: translateX(-50%);
+ display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px 8px;
+ width: max-content; max-width: calc(100% - 24px); padding: 8px 12px;
+ border: 1px solid var(--pd-a-bd); border-radius: var(--pd-r-md);
+ background: var(--pd-s5); color: var(--pd-t0); box-shadow: var(--pd-shadow-floating);
+ font-size: var(--pd-ts-sm); text-align: center; pointer-events: none;
+}
+.pv-trajectory-guide small { color: var(--pd-t1); font-size: var(--pd-ts-xs); }
+.pv-pane-no-cam {
+ position: absolute; inset: 0; display: flex; flex-direction: column;
+ align-items: center; justify-content: center; gap: 8px;
+ color: rgba(255,255,255,.4); font-size: 11px;
+}
+
+/* ── Right panel controls ───────────────────────────────────────────────── */
+.pv-control-block { padding: 10px 12px 0; }
+.pv-control-block__label {
+ font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em;
+ color: var(--pd-t1); margin-bottom: 6px;
+}
+
+.pv-shot-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; margin-bottom: 4px; }
+.pv-shot-chip {
+ padding: 6px 4px; border: 1px solid var(--pd-b1); border-radius: var(--pd-r-sm);
+ background: var(--pd-s1); color: var(--pd-t1);
+ font-size: 10px; cursor: pointer; text-align: center;
+ transition: all 0.15s var(--pd-ease-snap);
+}
+.pv-shot-chip:hover { border-color: var(--pd-b2); color: var(--pd-t0); background: var(--pd-s2); }
+.pv-shot-chip--active { border-color: var(--pd-a-bd); background: var(--pd-a-bg); color: var(--pd-a-on); font-weight: 500; }
+
+.pv-move-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; }
+.pv-move-chip {
+ padding: 5px 4px; border: 1px solid var(--pd-b1); border-radius: var(--pd-r-sm);
+ background: var(--pd-s1); color: var(--pd-t1);
+ font-size: 10px; cursor: pointer; text-align: center;
+ transition: all 0.15s var(--pd-ease-snap);
+}
+.pv-move-chip:hover { border-color: var(--pd-b2); color: var(--pd-t0); background: var(--pd-s2); }
+.pv-move-chip--active { border-color: oklch(0.75 0.14 155 / 0.4); background: oklch(0.75 0.14 155 / 0.1); color: var(--pd-green); font-weight: 500; }
+
+.pv-dur-row { display: grid; grid-template-columns: 1fr; gap: 8px; }
+.pv-dur-row > * { min-width: 0; }
+.pv-dur-field { display: flex; flex-direction: column; gap: 4px; font-size: 10px; color: var(--pd-muted); }
+.pv-dur-field input, .pv-dur-field select {
+ height: 28px; border: 1px solid var(--pd-line); border-radius: 5px;
+ background: var(--pd-surface-2); color: var(--pd-text); padding: 0 8px;
+ font: inherit; outline: none;
+}
+.pv-dur-field input:focus, .pv-dur-field select:focus { border-color: var(--pd-accent); }
+
+.pv-textarea {
+ width: 100%; border: 1px solid var(--pd-line); border-radius: 6px;
+ background: var(--pd-surface-2); color: var(--pd-text);
+ padding: 7px 9px; font: inherit; font-size: 11px; line-height: 1.5;
+ resize: vertical; outline: none; min-height: 60px;
+}
+.pv-textarea:focus { border-color: var(--pd-accent); }
+.pv-textarea::placeholder { color: var(--pd-faint); }
+
+/* Sliders */
+.pv-slider-list { display: flex; flex-direction: column; gap: 6px; }
+.pv-slider-row {
+ display: grid; grid-template-columns: 42px 1fr 36px; align-items: center; gap: 6px;
+ font-size: 10px; color: var(--pd-muted);
+}
+.pv-slider-row input[type=range] {
+ width: 100%; height: 3px; accent-color: var(--pd-accent);
+ cursor: pointer;
+}
+.pv-slider-val { text-align: right; color: var(--pd-text); font-variant-numeric: tabular-nums; }
+
+/* ── Output block ───────────────────────────────────────────────────────── */
+.pv-output-block {
+ margin: 10px 12px 0; padding: 10px 10px 10px;
+ border: 1px solid var(--pd-line); border-radius: 8px;
+ background: rgba(255,255,255,.02);
+}
+.pv-output-block__header {
+ display: flex; align-items: center; justify-content: space-between;
+ margin-bottom: 6px; font-size: 11px; font-weight: 600; color: var(--pd-text);
+}
+.pv-output-dot {
+ width: 7px; height: 7px; border-radius: 50%; background: var(--pd-muted);
+ transition: background .3s;
+}
+.pv-output-dot--done { background: var(--pd-green); }
+.pv-output-dot--recording { background: #f0b56a; animation: pv-pulse 1s ease-in-out infinite; }
+@keyframes pv-pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
+
+.pv-output-status { font-size: 10px; color: var(--pd-muted); margin-bottom: 8px; line-height: 1.5; }
+
+.pv-prompt-preview { border-top: 1px solid var(--pd-line); padding-top: 7px; }
+.pv-prompt-preview__label {
+ display: flex; align-items: center; justify-content: space-between;
+ font-size: 10px; font-weight: 600; color: var(--pd-muted); margin-bottom: 5px;
+}
+.pv-prompt-copy {
+ border: 0; background: transparent; color: var(--pd-accent); font-size: 9px; cursor: pointer;
+}
+.pv-prompt-copy:hover { color: var(--pd-accent-2); }
+.pv-prompt-preview__text {
+ max-height: 72px; overflow-y: auto; border-radius: 4px;
+ background: rgba(0,0,0,.25); padding: 6px 7px;
+ font-size: 9px; line-height: 1.55; color: rgba(255,255,255,.45);
+}
+
+/* ── Animation link ──────────────────────────────────────────────────────── */
+.pv-animation-link {
+ display: flex; align-items: center; gap: 7px; justify-content: center;
+ margin: 10px 12px 14px; height: 34px;
+ border: 1px dashed var(--pd-line); border-radius: 7px;
+ background: transparent; color: var(--pd-muted);
+ font-size: 11px; cursor: pointer;
+ transition: border-color .12s, color .12s, background .12s;
+}
+.pv-animation-link:hover { border-color: var(--pd-line-strong); color: var(--pd-text); background: var(--pd-surface-2); }
+
+/* ── Responsive ─────────────────────────────────────────────────────────── */
+@media (max-width: 1100px) {
+ .pv-layout { grid-template-columns: minmax(220px, 260px) minmax(0,1fr) minmax(300px, 320px); }
+ .pv-layout.is-scene-collapsed { grid-template-columns: 0 minmax(0,1fr) minmax(300px, 320px); }
+ .pv-layout.is-inspector-collapsed { grid-template-columns: minmax(220px, 260px) minmax(0,1fr) 0; }
+ .pv-layout.is-scene-collapsed.is-inspector-collapsed { grid-template-columns: 0 minmax(0,1fr) 0; }
+}
+@media (max-width: 860px) {
+ .pv-layout,
+ .pv-layout.is-scene-collapsed,
+ .pv-layout.is-inspector-collapsed,
+ .pv-layout.is-scene-collapsed.is-inspector-collapsed { grid-template-columns: minmax(0,1fr); }
+ .pv-layout > .pv-panel--left,
+ .pv-layout > .pv-panel--right { visibility: visible; }
+ .pv-panel--left {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ z-index: 31;
+ width: min(88vw, 300px);
+ border-right: 1px solid var(--pd-line);
+ box-shadow: 18px 0 40px rgba(0,0,0,.28);
+ transform: translateX(-100%);
+ transition: transform 180ms cubic-bezier(.16,1,.3,1);
+ }
+ .pv-panel--left.is-open { transform: translateX(0); }
+ .pv-panel-close { display: inline-flex; }
+ .pv-panel--right {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 30;
+ width: min(92vw, 360px);
+ border-left: 1px solid var(--pd-line);
+ box-shadow: -18px 0 40px rgba(0,0,0,.28);
+ transform: translateX(100%);
+ transition: transform 180ms cubic-bezier(.16,1,.3,1);
+ }
+ .pv-panel--right.is-open { transform: translateX(0); }
+ .pv-viewport-area { grid-template-columns: 1fr; }
+ .pv-viewport-pane--camera { display: none; }
+ .pv-mobile-inspector-close { display: inline-flex; }
+}
+@media (max-width: 640px) {
+ .pv-layout,
+ .pv-layout.is-scene-collapsed,
+ .pv-layout.is-inspector-collapsed,
+ .pv-layout.is-scene-collapsed.is-inspector-collapsed { grid-template-columns: minmax(0,1fr); }
+ .pv-layout > .pv-panel--left,
+ .pv-layout > .pv-panel--right { visibility: visible; }
+ .pv-panel--left { font-size: 0.95em; }
+}
+
+/* ── Prop scale inline controls ─────────────────────────────────────────── */
+.pv-scale-row {
+ display: grid; grid-template-columns: 14px 1fr 14px 1fr 14px 1fr;
+ align-items: center; gap: 3px; padding: 4px 6px;
+}
+.pv-scale-label { font-size: 9px; font-weight: 700; color: var(--pd-faint); text-align: center; }
+.pv-scale-input {
+ width: 100%; padding: 3px 5px; border-radius: 4px;
+ border: 1px solid var(--pd-line); background: var(--pd-surface);
+ color: var(--pd-text); font-size: 11px; text-align: right;
+}
+.pv-scale-input:focus { outline: none; border-color: var(--pd-accent); }
+
+
+/* ── Rotation chips ─────────────────────────────────────────────────────── */
+.pv-rot-row {
+ display: flex; align-items: center; gap: 4px;
+ padding: 4px 6px;
+}
+.pv-rot-label {
+ font-size: 9px; font-weight: 700; color: var(--pd-faint);
+ text-transform: uppercase; letter-spacing: .06em;
+ white-space: nowrap; flex: 0 0 auto; margin-right: 2px;
+}
+.pv-rot-chip {
+ flex: 1; padding: 4px 0; border-radius: 4px;
+ border: 1px solid var(--pd-line); background: transparent;
+ color: var(--pd-muted); font-size: 10px; font-weight: 600;
+ cursor: pointer; text-align: center;
+ transition: border-color .1s, color .1s, background .1s;
+}
+.pv-rot-chip:hover { border-color: var(--pd-line-strong); color: var(--pd-text); }
+.pv-rot-chip--active {
+ border-color: var(--previs-accent, #4f8cff);
+ color: var(--previs-accent, #4f8cff);
+ background: rgba(79,140,255,.1);
+}
+
+/* ── Viewport dock: same FloatingDock as the canvas toolbar ────────────────── */
+.pv-canvas-dock {
+ position: absolute;
+ left: 50%;
+ bottom: 14px;
+ z-index: 12;
+ transform: translateX(-50%);
+ max-width: calc(100% - 24px);
+}
+.pv-canvas-dock__menu {
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 10px);
+ z-index: 30;
+ width: 280px;
+ margin-left: -140px;
+ padding: 10px;
+ border-radius: var(--panel-radius, 14px);
+ backdrop-filter: blur(24px);
+}
+.pv-canvas-dock__menu-title { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 0 2px 8px; font-size: var(--pd-ts-sm); font-weight: 600; }
+.pv-canvas-dock__menu-title small { font-size: var(--pd-ts-2xs); font-weight: 400; }
+.pv-canvas-dock__menu-title.is-sub { padding-top: 10px; font-size: var(--pd-ts-xs); }
+.pv-canvas-dock__view-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; }
+.pv-canvas-dock__view-grid button,
+.pv-canvas-dock__camera-list button {
+ border: 0;
+ border-radius: var(--dock-item-radius, 8px);
+ background: var(--dock-command-bg, rgba(255,255,255,.04));
+ color: inherit;
+ transition: background 120ms var(--pd-ease-snap), color 120ms var(--pd-ease-snap);
+}
+.pv-canvas-dock__view-grid button { height: 28px; font-size: var(--pd-ts-2xs); font-weight: 600; letter-spacing: .04em; opacity: .78; }
+.pv-canvas-dock__view-grid button:hover,
+.pv-canvas-dock__camera-list button:hover { background: var(--dock-command-hover, rgba(255,255,255,.1)); opacity: 1; }
+.pv-canvas-dock__view-grid button.is-active,
+.pv-canvas-dock__camera-list button.is-active { background: var(--dock-command-active, #fff); color: var(--dock-command-active-text, #000); opacity: 1; }
+.pv-canvas-dock__view-grid button:focus-visible,
+.pv-canvas-dock__camera-list button:focus-visible { outline: 2px solid var(--pd-a0); outline-offset: 2px; }
+.pv-canvas-dock__camera-list { display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow-y: auto; }
+.pv-canvas-dock__camera-list button { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto 14px; align-items: center; gap: 8px; height: 30px; padding: 0 8px; text-align: left; font-size: var(--pd-ts-xs); }
+.pv-canvas-dock__camera-list button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pv-canvas-dock__camera-list button small { font-size: var(--pd-ts-2xs); opacity: .6; font-variant-numeric: tabular-nums; }
+.pv-cam-hud {
+ position: absolute; top: 12px; left: 12px; z-index: 6;
+ display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px;
+ border-radius: 999px; background: color-mix(in srgb, var(--pd-s1) 72%, transparent);
+ backdrop-filter: blur(16px); color: var(--pd-t0); font-size: var(--pd-ts-xs); font-weight: 600; pointer-events: none;
+}
+.pv-cam-hud__meta { color: var(--pd-t2); font-weight: 400; font-variant-numeric: tabular-nums; }
+@media (prefers-reduced-motion: reduce) {
+ .pv-canvas-dock__view-grid button, .pv-canvas-dock__camera-list button { transition: none; }
+}
+.pv-shot-list { display: flex; flex-direction: column; gap: 4px; margin: 10px 12px 0; }
+.pv-shot-list__item {
+ display: grid;
+ grid-template-columns: 18px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 7px;
+ width: 100%;
+ min-height: 34px;
+ padding: 6px 8px;
+ border: 1px solid var(--pd-line);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--pd-muted);
+ text-align: left;
+ cursor: pointer;
+}
+.pv-shot-list__item span { color: var(--pd-accent); font-variant-numeric: tabular-nums; }
+.pv-shot-list__item strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--pd-text); font-size: 10px; }
+.pv-shot-list__item small { color: var(--pd-faint); font-size: 9px; }
+.pv-shot-list__item:hover { border-color: var(--pd-line-strong); background: var(--pd-s2); }
+.pv-shot-list__item.is-active { border-color: var(--pd-a-bd); background: var(--pd-a-bg); }
+.pv-detail-block { padding-top: 2px; }
+.pv-detail-block .previs-inspector { padding: 0; }
+.pv-inline-timeline {
+ flex: 0 0 min(34vh, 360px);
+ min-height: 190px;
+ border-top: 1px solid var(--pd-b1);
+ background: var(--pd-s1);
+ overflow: hidden;
+}
+.pv-inline-timeline .previs-sequencer { height: 100% !important; border: 0 !important; background: transparent !important; }
+.pv-inline-timeline .previs-sequencer-resizer { display: none; }
+.pv-inline-timeline .previs-sequencer > header { background: var(--pd-s2); border-bottom-color: var(--pd-b0); }
+.pv-mobile-inspector-close { display: none; }
+.pv-move-grid--compact { margin-top: 8px; }
+
+@media (max-width: 860px) {
+ .pv-mobile-inspector-close { display: inline-flex; }
+ .pv-inline-timeline { flex-basis: 280px; }
+}
+
+/* Inspector swatches stay usable on narrow panels and expose clear interaction states. */
+.previs-desk .previs-actor-colors {
+ display: flex;
+ max-width: 100%;
+ min-width: 0;
+ align-items: center;
+ gap: 4px;
+ flex-wrap: wrap;
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding: 2px 0;
+ scrollbar-width: thin;
+}
+.previs-desk .previs-actor-color {
+ flex: 0 0 20px;
+ width: 20px;
+ height: 20px;
+ cursor: pointer;
+ transition: transform 120ms ease, box-shadow 120ms ease, outline-color 120ms ease;
+}
+.previs-desk .previs-actor-color:hover { transform: scale(1.08); }
+.previs-desk .previs-actor-color:focus-visible {
+ outline: 2px solid var(--pd-a0);
+ outline-offset: 2px;
+}
+.previs-desk .previs-actor-color.is-active {
+ outline: 2px solid var(--pd-a0);
+ outline-offset: 2px;
+ box-shadow: inset 0 0 0 1px var(--previs-swatch-inset), 0 0 0 2px var(--pd-a-bg);
+}
+.previs-desk .previs-actor-colors .ant-color-picker-trigger {
+ flex: 0 0 24px;
+ width: 24px;
+ height: 24px;
+ padding: 2px;
+ border-color: var(--pd-b1);
+ background: var(--pd-s2);
+}
+.previs-desk .previs-actor-colors .ant-color-picker-trigger:hover,
+.previs-desk .previs-actor-colors .ant-color-picker-trigger:focus-visible {
+ border-color: var(--pd-a0);
+}
diff --git a/web/src/components/canvas/previs/canvas-previs-workbench.tsx b/web/src/components/canvas/previs/canvas-previs-workbench.tsx
new file mode 100644
index 000000000..d1fa4799b
--- /dev/null
+++ b/web/src/components/canvas/previs/canvas-previs-workbench.tsx
@@ -0,0 +1,1567 @@
+import { cameraMoveOptions, poseEditBones, BoneRotationFields, shotSizeOptions, ObjectInspector, LightInspector, ShotInspector } from "./previs-inspectors";
+import { snapPrevisTime, advancePrevisPlayhead, resolvePrevisKeyframeRecord, resolvePrevisObjectTransformEdit, resolvePrevisCameraMoveKeyframes, resolvePrevisCameraAlignment } from "@/lib/canvas/previs/previs-animation-semantics";
+import { interpolatePrevisTransform, touchPrevisScene, createPrevisObject, createPrevisActor, PREVIS_ACTOR_COLORS, resolvePrevisActorColor, createPrevisModel, createPrevisBillboard, createPrevisCamera, createPrevisLight, upsertPrevisBoneKeyframe, removePrevisSceneKeyframe, setPrevisSceneKeyframeEasing, previsPoseLabel, previsBoneLabel, previsActorProfileForArchetype, previsActorArchetypeLabel, uniquePrevisName } from "@/lib/canvas/previs/previs-scene";
+import { type PrevisObject, type PrevisLight, type PrevisShot, type PrevisHumanoidBone, type PrevisQuat, type PrevisKeyframeDeleteTarget, type PrevisKeyframeEasing, type PrevisRig, type PrevisShotSize, type PrevisActorProfile } from "@/types/previs";
+import { resolvePrevisPlacementAnchor, resolvePrevisPlacement, type PrevisGroundPoint } from "@/lib/canvas/previs/previs-placement";
+import { uploadMediaFile } from "@/services/file-storage";
+import { saveRemoteUserDataNow, localSavedRemotePendingMessage } from "@/services/user-data-sync";
+import { Camera, Lightbulb, LampDesk, UserRound, Box, Circle, Cuboid, Square, FileUp, X, Undo2, Redo2, RotateCcw, Video, Save, Plus, WandSparkles, BoxSelect, Image as ImageIcon, Users, Route, Search } from "lucide-react";
+import { PrevisCanvasDock } from "./previs-canvas-dock";
+import { Dropdown } from "antd";
+import { nanoid } from "nanoid";
+import { type PrevisShortcutAction, resolvePrevisShortcut, blocksPrevisShortcut, releasePrevisFocusAfterPointer } from "@/lib/canvas/previs/previs-shortcuts";
+import { Euler, Vector3, type AnimationClip } from "three";
+import { compilePrevisPrompt } from "@/lib/canvas/previs/previs-prompt-compiler";
+import { Select } from "@/components/ui/base/select";
+import { PrevisViewport } from "./previs-viewport";
+import { CanvasPrevisOnboarding } from "@/components/canvas/previs/canvas-previs-onboarding";
+import { PrevisSequencer } from "./previs-sequencer";
+import { App, type MenuProps, Input, Button } from "antd";
+import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent, type PointerEvent } from "react";
+import { type PrevisViewportHandle } from "@/components/canvas/previs/previs-viewport";
+import { transcodeVideoToMp4 } from "@/lib/canvas/canvas-video-merge";
+import { canvasThemes } from "@/lib/canvas-theme";
+import { type PrevisTransaction, createPrevisTransaction, installPrevisTerminalListeners } from "@/lib/canvas/previs/previs-gesture-transaction";
+import { buildPrevisPathKeyframes } from "@/lib/canvas/previs/previs-path";
+import { recordPrevisDiagnostic } from "@/lib/canvas/previs/previs-diagnostics-recorder";
+import { shouldReinitializePrevisSession, isPrevisOutputSnapshotCurrent } from "@/lib/canvas/previs/previs-session";
+import { isPrevisPreviewRequestForWorkbench, previsPreviewRequestKey, type PrevisPreviewRequest } from "@/lib/canvas/previs/previs-preview";
+import { describePrevisSaveStatus, shouldOfferPrevisDraftRecovery, shouldBlockPrevisUnload, resolvePrevisCloseOutcome } from "@/lib/canvas/previs/previs-save-wiring";
+import { usePrevisSaveCoordinator } from "@/components/canvas/previs/use-previs-save-coordinator";
+import "./canvas-previs-workbench.css";
+import { useAssetStore, type ModelAsset } from "@/stores/use-asset-store";
+import { usePrevisWorkbenchStore } from "@/stores/canvas/use-previs-workbench-store";
+import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
+import type { CanvasNodeData } from "@/types/canvas";
+import type { PrevisCameraMove, PrevisRenderMode, PrevisScene, PrevisSceneOutput, PrevisTransform, PrevisVec3 } from "@/types/previs";
+export { cameraMoveOptions, poseOptions, shotSizeOptions } from "./previs-inspectors";
+
+export function CanvasPrevisWorkbench({ open, canvasId, scene, imageNodes, onboardingScope, onClose, onChange, onApply, onDeleteImageNode, onFlush }: { open: boolean; canvasId?: string; scene: PrevisScene | null; imageNodes: CanvasNodeData[]; onboardingScope: string; onClose: () => void; onChange: (scene: PrevisScene) => void; onApply: (output: PrevisSceneOutput) => Promise
; onDeleteImageNode: (nodeId: string) => void; onFlush?: () => void | Promise }) {
+ const { message, modal } = App.useApp();
+ const theme = canvasThemes[useActiveTheme()];
+ const effectiveCanvasId = useMemo(() => {
+ const explicit = canvasId?.trim();
+ if (explicit) return explicit;
+ if (typeof window === "undefined") return undefined;
+ const match = window.location.pathname.match(/^\/canvas\/([^/]+)/u);
+ return match ? decodeURIComponent(match[1]) : undefined;
+ }, [canvasId]);
+ const viewportRef = useRef(null);
+ const modelInputRef = useRef(null);
+ const [draft, setDraft] = useState(null);
+ const [history, setHistory] = useState([]);
+ const [future, setFuture] = useState([]);
+ const [saving, setSaving] = useState(false);
+ const [recording, setRecording] = useState(false);
+ const [lastClayExport, setLastClayExport] = useState<{ shotId: string; at: number } | null>(null);
+ const [trajectoryDrawing, setTrajectoryDrawing] = useState(false);
+ const [inspectorOpen, setInspectorOpen] = useState(false);
+ const [scenePanelOpen, setScenePanelOpen] = useState(false);
+ // 桌面端侧栏常驻,可收起换取沉浸视口;窄屏下改为 scenePanelOpen/inspectorOpen 控制的浮层。
+ const [sceneDocked, setSceneDocked] = useState(true);
+ const [inspectorDocked, setInspectorDocked] = useState(true);
+ const [compactLayout, setCompactLayout] = useState(() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(max-width: 860px)").matches);
+ useEffect(() => {
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
+ const media = window.matchMedia("(max-width: 860px)");
+ const update = () => setCompactLayout(media.matches);
+ update();
+ media.addEventListener("change", update);
+ return () => media.removeEventListener("change", update);
+ }, []);
+ const [onboardingRestartSignal, setOnboardingRestartSignal] = useState(0);
+ const viewMode = usePrevisWorkbenchStore((state) => state.viewMode);
+ const setViewMode = usePrevisWorkbenchStore((state) => state.setViewMode);
+ const selectedObjectId = usePrevisWorkbenchStore((state) => state.selectedObjectId);
+ const selectedLightId = usePrevisWorkbenchStore((state) => state.selectedLightId);
+ const transformMode = usePrevisWorkbenchStore((state) => state.transformMode);
+ const renderMode = usePrevisWorkbenchStore((state) => state.renderMode);
+ const playhead = usePrevisWorkbenchStore((state) => state.playhead);
+ const playing = usePrevisWorkbenchStore((state) => state.playing);
+ const selectedBone = usePrevisWorkbenchStore((state) => state.selectedBone);
+ const autoKey = usePrevisWorkbenchStore((state) => state.autoKey);
+ const sequencerHeight = usePrevisWorkbenchStore((state) => state.sequencerHeight);
+ const sequencerVisible = usePrevisWorkbenchStore((state) => state.sequencerVisible);
+ const setSelectedObjectId = usePrevisWorkbenchStore((state) => state.setSelectedObjectId);
+ const [selectedCameraId, setSelectedCameraId] = useState(null);
+ const [sceneQuery, setSceneQuery] = useState("");
+ const setSelectedLightId = usePrevisWorkbenchStore((state) => state.setSelectedLightId);
+ const setTransformMode = usePrevisWorkbenchStore((state) => state.setTransformMode);
+ const setRenderMode = usePrevisWorkbenchStore((state) => state.setRenderMode);
+ const setPlayhead = usePrevisWorkbenchStore((state) => state.setPlayhead);
+ const setPlaying = usePrevisWorkbenchStore((state) => state.setPlaying);
+ const setSelectedBone = usePrevisWorkbenchStore((state) => state.setSelectedBone);
+ const setAutoKey = usePrevisWorkbenchStore((state) => state.setAutoKey);
+ const setSequencerHeight = usePrevisWorkbenchStore((state) => state.setSequencerHeight);
+ const setSequencerVisible = usePrevisWorkbenchStore((state) => state.setSequencerVisible);
+ const resetWorkbench = usePrevisWorkbenchStore((state) => state.reset);
+ const stopPrevisEntityPointer = useCallback((event: MouseEvent | PointerEvent) => {
+ event.stopPropagation();
+ }, []);
+ const selectSceneObject = useCallback((id: string) => {
+ setSelectedObjectId(id);
+ setSelectedCameraId(null);
+ setSelectedBone(null);
+ setInspectorOpen(true); setInspectorDocked(true);
+ }, [setSelectedBone, setSelectedObjectId]);
+ const selectSceneCamera = useCallback((id: string) => {
+ setSelectedObjectId(null);
+ setSelectedLightId(null);
+ setSelectedCameraId(id);
+ setSelectedBone(null);
+ // CAM hides camera gizmos; keep the editable scene view so the selected camera is visible.
+ setViewMode("free");
+ setInspectorOpen(true); setInspectorDocked(true);
+ }, [setSelectedBone, setSelectedLightId, setSelectedObjectId, setViewMode]);
+ const assets = useAssetStore((state) => state.assets);
+ const addAsset = useAssetStore((state) => state.addAsset);
+ const modelAssets = useMemo(() => assets.filter((asset): asset is ModelAsset => asset.kind === "model"), [assets]);
+
+ const capabilities = { timeline: true, keyframes: sequencerVisible, bones: true, cameraTools: Boolean(selectedCameraId && !selectedObjectId && !selectedLightId), renderModes: PREVIS_RENDER_MODE_LABELS.map((option) => option.value) };
+ const renderModeOptions = PREVIS_RENDER_MODE_LABELS;
+
+ const draftRef = useRef(null);
+ const stagedRef = useRef(null);
+ const initializedSceneIdRef = useRef(null);
+ const onChangeRef = useRef(onChange);
+ const onFlushRef = useRef(onFlush);
+ useEffect(() => { onChangeRef.current = onChange; onFlushRef.current = onFlush; }, [onChange, onFlush]);
+
+ const closingRef = useRef(false);
+ const recoveryPromptedRef = useRef(null);
+ const [retrying, setRetrying] = useState(false);
+ const handledPreviewRequestsRef = useRef(new Set());
+
+ // 按 scene.id 持有唯一 coordinator:flush 先把 request.scene 写回项目,再等持久化完成。
+ const saveController = usePrevisSaveCoordinator({
+ sceneId: scene?.id ?? null,
+ initialScene: scene,
+ persistScene: (next) => onChangeRef.current(next),
+ flushPersistence: () => onFlushRef.current?.(),
+ });
+ const saveControllerRef = useRef(saveController);
+ saveControllerRef.current = saveController;
+ const saveIndicator = describePrevisSaveStatus(saveController.progress);
+
+ const retrySave = async () => {
+ setRetrying(true);
+ try {
+ if (await saveController.retry()) {
+ recordPrevisDiagnostic("PREVIS_SAVE_RETRY_RECOVERED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, userInitiated: true });
+ message.success("已保存到项目");
+ return;
+ }
+ // 只有真的存在合法本地候选才敢说草稿已保留。
+ const draftStored = Boolean(saveController.restoreCandidate());
+ recordPrevisDiagnostic("PREVIS_SAVE_RETRY_FAILED", { sceneId: draftRef.current?.id, revision: saveController.progress.revision, draftStored, userInitiated: true });
+ if (!draftStored) recordPrevisDiagnostic("PREVIS_SAVE_DRAFT_UNAVAILABLE", { sceneId: draftRef.current?.id, revision: saveController.progress.revision });
+ if (draftStored) message.error("远端保存失败,本地草稿已保留,可稍后重试");
+ else message.error("远端和本地都未保存,请不要关闭预演台并继续重试");
+ } finally {
+ setRetrying(false);
+ }
+ };
+
+ const writeDraft = useCallback((next: PrevisScene | null) => {
+ draftRef.current = next;
+ setDraft(next);
+ }, []);
+
+ /**
+ * 仅镜像当前 draft 到项目 previsScenes,不产生 canonical 提交。
+ * 用于取消预览、idle pagehide、卸载兜底 —— 这些都不是新的用户改动。
+ */
+ const mirrorDraft = useCallback(() => {
+ const current = draftRef.current;
+ if (!current || initializedSceneIdRef.current !== current.id) return;
+ onChangeRef.current(current);
+ }, []);
+
+ /** 真实 canonical 提交:先交给 coordinator(本地草稿 + 远端保存),再镜像到项目。 */
+ const commitDraft = useCallback(() => {
+ const current = draftRef.current;
+ if (!current || initializedSceneIdRef.current !== current.id) return;
+ saveControllerRef.current?.commitScene(current);
+ onChangeRef.current(current);
+ }, []);
+
+ const writeAndPublish = useCallback((next: PrevisScene) => {
+ writeDraft(next);
+ saveControllerRef.current?.commitScene(next);
+ onChangeRef.current(next);
+ }, [writeDraft]);
+
+ // 会话初始化只认 scene id:同 id 的父级镜像回流不得重建会话。
+ useEffect(() => {
+ if (!open || !scene) return;
+ if (!shouldReinitializePrevisSession({ initializedSceneId: initializedSceneIdRef.current, nextSceneId: scene.id })) return;
+ const next = structuredClone(scene);
+ next.objects = next.objects.map((object) => object.kind === "actor" || object.primitive === "character" ? { ...object, color: resolvePrevisActorColor(object.color), archetype: object.archetype || "adult" } : object);
+ next.shots = next.shots.map((shot) => ({ ...shot, fps: shot.fps || 24 }));
+ stagedRef.current?.end("cancel");
+ initializedSceneIdRef.current = scene.id;
+ writeDraft(next);
+ setHistory([]);
+ setFuture([]);
+ resetWorkbench();
+ }, [open, resetWorkbench, scene, writeDraft]);
+
+ // 父级项目收到 Agent 的预演台语义补丁后,scene.id 不变但 updatedAt 会变化;
+ // 不能只按 scene.id 忽略,否则打开着的工作台永远看不到 Agent 的修改。
+ useEffect(() => {
+ if (!open || !scene || initializedSceneIdRef.current !== scene.id) return;
+ const current = draftRef.current;
+ if (!current || scene.updatedAt === current.updatedAt) return;
+ stagedRef.current?.end("cancel");
+ const next = structuredClone(scene);
+ writeDraft(next);
+ setHistory([]);
+ setFuture([]);
+ setSelectedObjectId(null);
+ setSelectedLightId(null);
+ setSelectedBone(null);
+ setPlayhead(0);
+ message.info("Agent 已更新预演场景");
+ }, [message, open, scene, setPlayhead, setSelectedBone, setSelectedLightId, setSelectedObjectId, writeDraft]);
+
+ // 打开会话时检查合法本地恢复候选:同一场景只提示一次,恢复/放弃都必须有明确结果。
+ useEffect(() => {
+ if (!open || !scene) return;
+ if (recoveryPromptedRef.current === scene.id) return;
+ recoveryPromptedRef.current = scene.id;
+
+ const controller = saveControllerRef.current;
+ const candidate = controller?.restoreCandidate() ?? null;
+ if (!controller || !candidate) return;
+ if (!shouldOfferPrevisDraftRecovery({ candidate, authoritativeScene: scene })) return;
+
+ modal.confirm({
+ title: "发现未保存的本地草稿",
+ content: `这个镜头存在一份比项目更新的本地草稿(修订 ${candidate.revision})。恢复后会立即写回项目并保存。`,
+ okText: "恢复草稿",
+ cancelText: "放弃草稿",
+ closable: false,
+ mask: { closable: false },
+ keyboard: false,
+ onOk: () => {
+ if (!controller.restoreDraft(candidate)) {
+ message.error("草稿恢复失败,已保留当前场景");
+ return;
+ }
+ writeDraft(candidate.scene);
+ message.success("已恢复本地草稿并写回项目");
+ },
+ onCancel: () => {
+ if (controller.discardDraft()) message.success("已放弃本地草稿");
+ else message.error("草稿删除失败,下次打开可能仍会提示");
+ },
+ });
+ }, [message, modal, open, scene, writeDraft]);
+
+ const activeShot = draft?.shots?.find((item) => item.id === draft.activeShotId) || draft?.shots?.[0] || null;
+ const activeCamera = draft?.cameras?.find((item) => item.id === activeShot?.cameraId) || draft?.cameras?.[0] || null;
+ useEffect(() => {
+ const cameras = draft?.cameras || [];
+ if (!cameras.length) {
+ setSelectedCameraId(null);
+ return;
+ }
+ if (!selectedCameraId || !cameras.some((camera) => camera.id === selectedCameraId)) {
+ setSelectedCameraId(null);
+ }
+ }, [activeCamera?.id, draft?.cameras, selectedCameraId]);
+ useEffect(() => {
+ if (activeShot?.id) setSelectedCameraId(activeShot.cameraId || null);
+ }, [activeShot?.id]);
+ const compiledPrompt = useMemo(() => draft && activeShot ? compilePrevisPrompt(draft, activeShot) : "", [activeShot, draft]);
+ const selectedObject = draft?.objects?.find((item) => item.id === selectedObjectId) || null;
+ const selectedLight = draft?.lights?.find((item) => item.id === selectedLightId) || null;
+ const selectedCamera = draft?.cameras?.find((item) => item.id === selectedCameraId) || null;
+ const inspectorCamera = selectedCamera || activeCamera;
+ // 写入关键帧的目的时间用吸附值;取值/显示/手势起点一律用 raw playhead,
+ // 否则处在两个帧格之间时 AutoKey OFF 的增量会从错误起点计算而产生漂移。
+ const snappedPlayhead = snapPrevisTime(playhead, activeShot?.fps || 24);
+ const selectedObjectRendered = selectedObject ? interpolatePrevisTransform(selectedObject.transform, selectedObject.keyframes, playhead) : null;
+
+ useEffect(() => {
+ if (!playing || !activeShot) return;
+ let frame = 0;
+ let last = performance.now();
+ let pending = 0;
+ const frameInterval = 1 / Math.max(1, Math.min(120, activeShot.fps || 24));
+ const tick = (now: number) => {
+ pending += Math.max(0, (now - last) / 1000);
+ last = now;
+ if (pending >= frameInterval) {
+ const elapsed = Math.floor(pending / frameInterval) * frameInterval;
+ pending -= elapsed;
+ setPlayhead(advancePrevisPlayhead(usePrevisWorkbenchStore.getState().playhead, elapsed, activeShot.duration));
+ }
+ frame = requestAnimationFrame(tick);
+ };
+ frame = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(frame);
+ }, [activeShot, playing, setPlayhead]);
+
+ const commit = useCallback((updater: (current: PrevisScene) => PrevisScene) => {
+ // 普通提交前先终结暂存手势,避免新动作消费旧 base。
+ stagedRef.current?.end("commit");
+ const current = draftRef.current;
+ if (!current) return;
+ setHistory((items) => [...items.slice(-49), structuredClone(current)]);
+ setFuture([]);
+ writeAndPublish(touchPrevisScene(updater(current)));
+ }, [writeAndPublish]);
+
+ /** 暂存型手势(数值滑杆):实时预览写草稿但不产生历史,也不镜像到项目。 */
+ const stagedTransaction = useMemo(() => createPrevisTransaction({
+ read: () => draftRef.current,
+ // 取消:恢复快照且绝不发布被取消的值。
+ restore: (snapshot) => writeDraft(snapshot),
+ commit: (from) => {
+ setHistory((items) => [...items.slice(-49), from]);
+ setFuture([]);
+ // 手势成功终态是真实 canonical 提交。
+ commitDraft();
+ },
+ setActive: () => undefined,
+ }), [commitDraft, writeDraft]);
+ stagedRef.current = stagedTransaction;
+
+ const stageGesture = useCallback((updater: (current: PrevisScene) => PrevisScene) => {
+ const current = draftRef.current;
+ if (!current) return;
+ stagedTransaction.begin();
+ writeDraft(touchPrevisScene(updater(current)));
+ }, [stagedTransaction, writeDraft]);
+
+ /** 无历史但持久的变化(标题、rig/motionClips 等)同样要镜像。 */
+ const replaceWithoutHistory = useCallback((updater: (current: PrevisScene) => PrevisScene) => {
+ const current = draftRef.current;
+ if (current) writeAndPublish(touchPrevisScene(updater(current)));
+ }, [writeAndPublish]);
+
+ // 暂存手势的终止生命周期:常驻安装,非活跃时 end 为空操作。
+ useEffect(() => installPrevisTerminalListeners(stagedTransaction, {
+ window,
+ document,
+ isHidden: () => document.visibilityState === "hidden",
+ }), [stagedTransaction]);
+
+ // 切换选择/骨骼、关闭或卸载前必须先终止旧手势,不能让新选择消费旧 base。
+ useEffect(() => () => stagedTransaction.end("cancel"), [open, selectedBone, selectedObjectId, stagedTransaction]);
+
+ // 离开页面:active 预览由 end("commit") 完成真实提交,idle 只镜像;落盘统一交给 controller。
+ useEffect(() => {
+ const onPageHide = () => {
+ if (stagedTransaction.active()) stagedTransaction.end("commit");
+ else mirrorDraft();
+ // 只调用 handlePageHide:dirty 时它自己会 persist + flush,组件再叠一次就是重复落盘。
+ void saveControllerRef.current?.handlePageHide();
+ };
+ // 异步 flush 不可能阻塞卸载:这里只同步声明「仍有未确认改动」,让浏览器自己弹保护。
+ const onBeforeUnload = (event: BeforeUnloadEvent) => {
+ const controller = saveControllerRef.current;
+ if (!controller || !shouldBlockPrevisUnload(controller.progress)) return;
+ event.preventDefault();
+ event.returnValue = "";
+ };
+ window.addEventListener("pagehide", onPageHide);
+ window.addEventListener("beforeunload", onBeforeUnload);
+ return () => {
+ window.removeEventListener("pagehide", onPageHide);
+ window.removeEventListener("beforeunload", onBeforeUnload);
+ };
+ }, [mirrorDraft, stagedTransaction]);
+
+ // 卸载兜底:只把最新 draft 镜像回项目,不制造新的 canonical revision。
+ useEffect(() => () => {
+ stagedRef.current?.end("cancel");
+ mirrorDraft();
+ }, [mirrorDraft]);
+
+ const undo = () => {
+ const previous = history.at(-1);
+ if (!previous || !draft) return;
+ setHistory((items) => items.slice(0, -1));
+ setFuture((items) => [structuredClone(draft), ...items].slice(0, 50));
+ writeAndPublish(previous);
+ };
+ const redo = () => {
+ const next = future[0];
+ if (!next || !draft) return;
+ setFuture((items) => items.slice(1));
+ setHistory((items) => [...items, structuredClone(draft)].slice(-50));
+ writeAndPublish(next);
+ };
+
+ /**
+ * 关闭统一入口:取消未结束的预览、镜像当前 draft,再按 prepareClose 决策是否真的退出。
+ * 这里只镜像不提交:取消预览不是新的 canonical 变化。
+ */
+ const closeWorkbench = () => {
+ if (closingRef.current) return;
+ closingRef.current = true;
+ stagedTransaction.end("cancel");
+ mirrorDraft();
+
+ // 异步生成缩略图,不阻塞关闭流程
+ if (viewportRef.current && draftRef.current && activeShot) {
+ const currentScene = draftRef.current;
+ const currentShot = activeShot;
+ setTimeout(() => {
+ void (async () => {
+ try {
+ const thumbnail = await viewportRef.current?.capture("clay");
+ if (thumbnail) {
+ await onApply({
+ scene: currentScene,
+ shot: currentShot,
+ prompt: compilePrevisPrompt(currentScene, currentShot),
+ beauty: thumbnail,
+ clayVideo: undefined,
+ clayVideoMimeType: ""
+ });
+ }
+ } catch (error) {
+ console.warn("预演台缩略图后台生成失败", error);
+ }
+ })();
+ }, 100);
+ }
+
+ void (async () => {
+ let decision;
+ try {
+ decision = resolvePrevisCloseOutcome(await saveController.prepareClose());
+ } catch {
+ message.error("关闭前的保存检查失败,已留在预演台");
+ closingRef.current = false;
+ return;
+ }
+
+ if (decision.kind === "close") {
+ onClose();
+ return;
+ }
+ if (decision.kind === "blocked") {
+ recordPrevisDiagnostic("PREVIS_CLOSE_BLOCKED", { sceneId: draftRef.current?.id, saveOutcome: "stay", revision: saveController.progress.revision, draftStored: saveController.progress.draftStored });
+ message.error(decision.message);
+ closingRef.current = false;
+ return;
+ }
+
+ // 确认框存续期间保持上锁,否则重复点击会叠出多个弹窗。
+ modal.confirm({
+ title: "远端保存失败",
+ content: decision.message,
+ okText: "仍然离开",
+ cancelText: "留在预演台",
+ closable: false,
+ mask: { closable: false },
+ keyboard: false,
+ onOk: () => onClose(),
+ onCancel: () => {
+ closingRef.current = false;
+ },
+ });
+ })();
+ };
+
+ const updateObject = (id: string, patch: Partial) => commit((current) => ({ ...current, objects: current.objects.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
+ const duplicateObject = (id: string) => {
+ const source = draft?.objects.find((item) => item.id === id);
+ if (!source) return;
+ const copy = structuredClone(source);
+ copy.id = nanoid();
+ copy.name = `${source.name} 副本`;
+ copy.transform = { ...copy.transform, position: [source.transform.position[0] + 0.8, source.transform.position[1], source.transform.position[2] + 0.8] };
+ copy.keyframes = copy.keyframes.map((keyframe) => ({ ...keyframe, id: nanoid() }));
+ copy.boneTracks = copy.boneTracks?.map((track) => ({ ...track, keyframes: track.keyframes.map((keyframe) => ({ ...keyframe, id: nanoid() })) }));
+ commit((current) => ({ ...current, objects: [...current.objects, copy] }));
+ setSelectedObjectId(copy.id);
+ setSelectedBone(null);
+ message.success("对象已复制");
+ };
+ const updateLight = (id: string, patch: Partial) => commit((current) => ({ ...current, lights: current.lights.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
+ const updateShot = (id: string, patch: Partial) => commit((current) => ({ ...current, shots: current.shots.map((item) => (item.id === id ? { ...item, ...patch } : item)) }));
+ const removeObject = (id: string) => {
+ commit((current) => ({ ...current, objects: current.objects.filter((item) => item.id !== id) }));
+ if (selectedObjectId === id) {
+ setSelectedObjectId(null);
+ setSelectedBone(null);
+ }
+ };
+ const removeLight = (id: string) => {
+ commit((current) => ({ ...current, lights: current.lights.filter((item) => item.id !== id) }));
+ if (selectedLightId === id) setSelectedLightId(null);
+ };
+ const removeCamera = (id: string) => {
+ if (!draft || draft.cameras.length <= 1) {
+ message.warning("至少保留一台摄影机");
+ return;
+ }
+ const fallback = draft.cameras.find((item) => item.id !== id);
+ if (!fallback) return;
+ commit((current) => ({
+ ...current,
+ cameras: current.cameras.filter((item) => item.id !== id),
+ shots: current.shots.map((shot) => shot.cameraId === id ? { ...shot, cameraId: fallback.id } : shot),
+ }));
+ };
+
+ /**
+ * 所有「新增到场景」的唯一入口。
+ * 在 commit 内读取一次 placement intent:因此模型上传等异步路径拿到的是
+ * 「点击添加完成那一刻」的意图,而不是发起上传时捕获的过时坐标。
+ * 锚点只提供 XZ,Y 严格保留构造器给定值,再交给 resolvePrevisPlacement 做碰撞避让。
+ */
+ const addObject = (object: PrevisObject) => {
+ commit((current) => {
+ const anchored = resolvePrevisPlacementAnchor({
+ intent: viewportRef.current?.readPlacementIntent() ?? null,
+ fallback: object.transform.position,
+ });
+ const position = resolvePrevisPlacement({ object: { ...object, transform: { ...object.transform, position: anchored } }, existing: current.objects });
+ return { ...current, objects: [...current.objects, { ...object, transform: { ...object.transform, position } }] };
+ });
+ setSelectedObjectId(object.id);
+ };
+
+ const addPrimitive = (primitive: PrevisObject["primitive"], name: string) => addObject(createPrevisObject(primitive, name));
+
+ const addActor = () => {
+ const existingActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || [];
+ const colorIndex = existingActors.length % PREVIS_ACTOR_COLORS.length;
+ addObject(createPrevisActor(`演员 ${existingActors.length + 1}`, [0, 0, 0], PREVIS_ACTOR_COLORS[colorIndex]));
+ };
+
+ const addModelAsset = (asset: ModelAsset) => addObject(createPrevisModel({ name: asset.title, assetId: asset.id, storageKey: asset.data.storageKey, url: asset.data.url, mimeType: asset.data.mimeType }));
+
+ const uploadModel = async (file?: File) => {
+ if (!file || !/\.(glb|gltf)$/i.test(file.name)) return;
+ const uploaded = await uploadMediaFile(file, "model");
+ const assetId = addAsset({ kind: "model", title: file.name.replace(/\.(glb|gltf)$/i, ""), coverUrl: "", tags: ["3D模型"], source: "预演台", data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: file.name }, metadata: { source: "previs" } });
+ const asset = useAssetStore.getState().assets.find((item): item is ModelAsset => item.id === assetId && item.kind === "model");
+ if (asset) addModelAsset(asset);
+ try {
+ await saveRemoteUserDataNow();
+ message.success("3D 模型已加入场景和素材库");
+ } catch (error) {
+ message.warning(localSavedRemotePendingMessage("3D 模型已加入场景和本机素材库", error));
+ }
+ };
+
+ const addBillboard = async (node: CanvasNodeData) => {
+ const url = node.metadata?.content || node.metadata?.storageKey;
+ if (!url) {
+ message.warning("该图片节点没有内容,无法添加到场景");
+ return;
+ }
+ // 如果是 storageKey,需要解析成实际URL
+ let actualUrl = url;
+ if (node.metadata?.storageKey && !url.startsWith('http') && !url.startsWith('blob:') && !url.startsWith('data:')) {
+ try {
+ const { resolveImageUrl } = await import("@/services/image-storage");
+ actualUrl = await resolveImageUrl(node.metadata.storageKey, url);
+ } catch (error) {
+ console.warn("解析图片URL失败,使用原URL:", error);
+ }
+ }
+ const billboard = createPrevisBillboard(node.title || "背板", actualUrl, node.metadata?.storageKey, node.id);
+ addObject(billboard);
+ message.success(`已添加背板:${node.title || "图片"}`);
+ };
+ const setPanoramaEnvironment = (node: CanvasNodeData) => {
+ const url = node.metadata?.content;
+ if (!url) return;
+ commit((current) => ({ ...current, environment: { mode: "panorama", url, storageKey: node.metadata?.storageKey, sourceNodeId: node.id, rotationY: 0, opacity: 1, exposure: 0, useAsLighting: false, lightingIntensity: 0.2 } }));
+ message.success("已设为 360° 背景;请确认图片是 2:1 全景图");
+ };
+ const clearPanoramaEnvironment = () => commit((current) => ({ ...current, environment: { mode: "color" } }));
+
+ const copyCompiledPrompt = async () => {
+ if (!compiledPrompt) return;
+ try {
+ await navigator.clipboard.writeText(compiledPrompt);
+ message.success("已复制可直接交给视频模型的提示词");
+ } catch {
+ message.warning("当前浏览器不允许自动复制,请从提示词预览中手动复制");
+ }
+ };
+
+ const addCamera = () => {
+ const camera = createPrevisCamera(`摄影机 ${draft?.cameras.length ? draft.cameras.length + 1 : 1}`);
+ commit((current) => ({ ...current, cameras: [...current.cameras, camera] }));
+ if (activeShot) updateShot(activeShot.id, { cameraId: camera.id });
+ };
+
+ const addLight = (type: PrevisLight["type"] = "point", label = "灯光", position: PrevisVec3 = [2, 3, 2], intensity = 1.5) => {
+ const light = createPrevisLight(type, `${label} ${draft?.lights.length ? draft.lights.length + 1 : 1}`, position, intensity);
+ commit((current) => ({ ...current, lights: [...current.lights, light] }));
+ setSelectedLightId(light.id);
+ };
+
+ const addCameraMenuItems: MenuProps["items"] = [
+ { key: "camera", icon: , label: "添加摄影机", onClick: addCamera },
+ ];
+ const addLightMenuItems: MenuProps["items"] = [
+ { key: "directional", icon: , label: "方向光", onClick: () => addLight("directional", "方向光", [4, 6, 4], 2.4) },
+ { key: "point", icon: , label: "点光源", onClick: () => addLight("point", "点光源") },
+ { key: "spot", icon: , label: "聚光灯", onClick: () => addLight("spot", "聚光灯", [2, 4, 2], 2) },
+ { key: "ambient", icon: , label: "环境光", onClick: () => addLight("ambient", "环境光", [0, 0, 0], 0.65) },
+ ];
+ const addObjectMenuItems: MenuProps["items"] = [
+ { key: "actor", icon: , label: "演员", onClick: addActor },
+ { key: "box", icon: , label: "立方体", onClick: () => addPrimitive("box", "立方体") },
+ { key: "sphere", icon: , label: "球体", onClick: () => addPrimitive("sphere", "球体") },
+ { key: "cylinder", icon: , label: "圆柱", onClick: () => addPrimitive("cylinder", "圆柱") },
+ { key: "model", icon: , label: "上传模型", onClick: () => modelInputRef.current?.click() },
+ ];
+
+ const addShot = () => {
+ if (!activeCamera) return;
+ const shot: PrevisShot = { id: nanoid(), name: `镜头 ${(draft?.shots.length || 0) + 1}`, cameraId: activeCamera.id, duration: 5, fps: 24, shotSize: "medium", cameraMove: "static", prompt: "" };
+ commit((current) => ({ ...current, shots: [...current.shots, shot], activeShotId: shot.id }));
+ setPlayhead(0);
+ };
+
+ const addObjectKeyframe = () => {
+ if (!selectedObject) return;
+ // 取值用 raw playhead(视口真正渲染的时间),写入用 snapped 目的时间。
+ const record = resolvePrevisKeyframeRecord({ base: selectedObject.transform, keyframes: selectedObject.keyframes, rawTime: playhead, snappedTime: snappedPlayhead });
+ updateObject(selectedObject.id, { keyframes: record.keyframes });
+ };
+
+ const addCameraKeyframe = () => {
+ if (!activeCamera) return;
+ commit((current) => ({
+ ...current,
+ cameras: current.cameras.map((item) => item.id === activeCamera.id
+ ? { ...item, keyframes: resolvePrevisKeyframeRecord({ base: item.transform, keyframes: item.keyframes, rawTime: playhead, snappedTime: snappedPlayhead }).keyframes }
+ : item),
+ }));
+ };
+
+ const recordSelectedKeyframe = () => {
+ if (selectedObject && selectedBone) {
+ const rotation = selectedObject.boneOverrides?.[selectedBone as PrevisHumanoidBone] || [0, 0, 0, 1] as PrevisQuat;
+ updateObject(selectedObject.id, { boneTracks: upsertPrevisBoneKeyframe(selectedObject.boneTracks || [], selectedBone as PrevisHumanoidBone, snappedPlayhead, rotation) });
+ return;
+ }
+ if (selectedObject) addObjectKeyframe();
+ else addCameraKeyframe();
+ };
+
+ /**
+ * 时间轴删除关键帧的唯一入口。
+ *
+ * 未命中(对象/摄影机/关键帧已不存在)时 removePrevisSceneKeyframe 返回同一引用,
+ * 此时不进 commit:不记历史、不产生修订、不触发保存。
+ */
+ const deleteKeyframe = useCallback((target: PrevisKeyframeDeleteTarget) => {
+ const current = draftRef.current;
+ if (!current || removePrevisSceneKeyframe(current, target) === current) return;
+ commit((scene) => removePrevisSceneKeyframe(scene, target));
+ }, [commit]);
+
+ const setKeyframeEasing = useCallback((target: PrevisKeyframeDeleteTarget, easing: PrevisKeyframeEasing) => {
+ const current = draftRef.current;
+ if (!current || setPrevisSceneKeyframeEasing(current, target, easing) === current) return;
+ commit((scene) => setPrevisSceneKeyframeEasing(scene, target, easing));
+ }, [commit]);
+
+ /**
+ * 快捷键执行器。放在 ref 里:监听只在 open 变化时注册一次,
+ * 但每次渲染都能拿到最新的选择、历史和 draft,避免闭包读到过期状态。
+ *
+ * 返回值表示「动作真的执行了」,只有执行了才 preventDefault:
+ * 没有选中对象时的 Delete 仍然交还给浏览器。
+ */
+ const runShortcut = (action: PrevisShortcutAction): boolean => {
+ switch (action.kind) {
+ case "transform-mode":
+ setTransformMode(action.mode);
+ return true;
+ case "delete-selected":
+ if (selectedObject) {
+ removeObject(selectedObject.id);
+ return true;
+ }
+ if (selectedLight) {
+ removeLight(selectedLight.id);
+ return true;
+ }
+ return false;
+ case "undo":
+ if (!history.length) return false;
+ undo();
+ return true;
+ case "redo":
+ if (!future.length) return false;
+ redo();
+ return true;
+ case "toggle-visibility":
+ if (!selectedObject) return false;
+ updateObject(selectedObject.id, { visible: !selectedObject.visible });
+ return true;
+ case "deselect":
+ if (!selectedObjectId && !selectedLightId && !selectedBone) return false;
+ setSelectedObjectId(null);
+ setSelectedLightId(null);
+ setSelectedBone(null);
+ return true;
+ case "toggle-play":
+ setPlaying(!playing);
+ return true;
+ }
+ };
+ const runShortcutRef = useRef(runShortcut);
+ runShortcutRef.current = runShortcut;
+
+ // 预演台是全屏浮层,快捷键挂在 window;焦点落在任何交互控件内时交还给该控件,
+ // 关键帧按钮再额外拦截自己的 Enter/Space/Delete/Backspace。
+ useEffect(() => {
+ if (!open) return;
+ const onKeyDown = (event: KeyboardEvent) => {
+ const action = resolvePrevisShortcut({
+ key: event.key,
+ ctrlKey: event.ctrlKey,
+ metaKey: event.metaKey,
+ shiftKey: event.shiftKey,
+ altKey: event.altKey,
+ isInteractiveTarget: blocksPrevisShortcut(event.target),
+ });
+ if (!action) return;
+ if (runShortcutRef.current(action)) event.preventDefault();
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [open]);
+
+ /** 对象 transform 编辑的唯一入口:gizmo 与检查器共用同一套静态/动画语义。 */
+ const handleObjectTransform = useCallback((id: string, from: PrevisTransform, to: PrevisTransform) => {
+ commit((current) => ({
+ ...current,
+ objects: current.objects.map((item) => {
+ if (item.id !== id) return item;
+ const edit = resolvePrevisObjectTransformEdit({ base: item.transform, keyframes: item.keyframes, rendered: from, edited: to, autoKey, time: snappedPlayhead });
+ return { ...item, transform: edit.transform, keyframes: edit.keyframes };
+ }),
+ }));
+ }, [autoKey, commit, snappedPlayhead]);
+
+ /** 摄影机 transform gizmo 写入:直接覆盖摄影机 transform(不走 object autoKey 逻辑)。 */
+ const handleCameraTransform = useCallback((id: string, from: PrevisTransform, to: PrevisTransform) => {
+ commit((current) => ({
+ ...current,
+ cameras: current.cameras.map((item) => {
+ if (item.id !== id) return item;
+ // 机位取景由 target 决定:平移时保持盯住原焦点;旋转时按新朝向把焦点推到同样距离。
+ const rotated = to.rotation.some((value, index) => Math.abs(value - from.rotation[index]) > 1e-4);
+ if (!rotated) return { ...item, transform: { ...to, scale: [1, 1, 1] } };
+ const distance = Math.max(0.5, new Vector3(...item.target).distanceTo(new Vector3(...from.position)));
+ const forward = new Vector3(0, 0, -1).applyEuler(new Euler(...to.rotation)).multiplyScalar(distance);
+ const target: PrevisVec3 = [to.position[0] + forward.x, to.position[1] + forward.y, to.position[2] + forward.z];
+ return { ...item, transform: { ...to, scale: [1, 1, 1] }, target };
+ }),
+ }));
+ }, [commit]);
+
+ /** 骨骼写入语义:静态覆盖 + autoKey 时在吸附播放头补关键帧。gizmo 与数值编辑器共用。 */
+ const writeBoneRotation = useCallback((id: string, bone: string, rotation: PrevisQuat, mode: "stage" | "commit") => {
+ const write = mode === "stage" ? stageGesture : commit;
+ write((current) => ({
+ ...current,
+ objects: current.objects.map((item) => item.id === id ? {
+ ...item,
+ boneOverrides: { ...item.boneOverrides, [bone]: rotation },
+ boneTracks: autoKey ? upsertPrevisBoneKeyframe(item.boneTracks || [], bone as PrevisHumanoidBone, snappedPlayhead, rotation) : item.boneTracks,
+ } : item),
+ }));
+ }, [autoKey, commit, snappedPlayhead, stageGesture]);
+
+ const handleBoneTransform = useCallback((id: string, bone: string, rotation: PrevisQuat) => writeBoneRotation(id, bone, rotation, "commit"), [writeBoneRotation]);
+
+ const handleActorRigReady = useCallback((id: string, rig: PrevisRig, animations: AnimationClip[]) => {
+ replaceWithoutHistory((current) => ({
+ ...current,
+ objects: current.objects.map((item) => {
+ if (item.id !== id) return item;
+ const existing = item.motionClips || [];
+ const motionClips = existing.length ? existing : animations.map((clip) => ({ id: nanoid(), name: clip.name || "动作片段", sourceAnimation: clip.name, start: 0, duration: Math.max(0.1, clip.duration), playbackRate: 1, loop: true }));
+ return { ...item, rig, motionClips };
+ }),
+ }));
+ }, [replaceWithoutHistory]);
+
+ const applyCameraMove = () => {
+ if (!activeCamera || !activeShot) return;
+ const cameraId = activeCamera.id;
+ const move = activeShot.cameraMove;
+ const duration = activeShot.duration;
+ commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === cameraId ? { ...item, keyframes: resolvePrevisCameraMoveKeyframes(item.keyframes, item.transform, cameraMoveTransform(item.transform, move), duration) } : item) }));
+ message.success("已更新运镜首尾关键帧,可在动画模式继续编辑");
+ };
+
+ const alignCameraToView = () => {
+ if (!activeCamera) return;
+ const transform = viewportRef.current?.readCameraTransform();
+ if (!transform) return;
+ commit((current) => ({ ...current, cameras: current.cameras.map((item) => item.id === activeCamera.id ? resolvePrevisCameraAlignment(item, transform, snappedPlayhead) : item) }));
+ message.success("摄影机已对齐当前视图");
+ };
+
+ const applyToCanvas = async () => {
+ stagedTransaction.end("commit");
+ const current = draftRef.current;
+ if (!current || !activeShot || !viewportRef.current) return;
+ const expected = { scene: current, shotId: activeShot.id };
+ setSaving(true);
+ try {
+ const beauty = await viewportRef.current.capture("beauty");
+ if (!isPrevisOutputSnapshotCurrent(draftRef.current, expected)) throw new Error("输出期间场景或镜头已变化,请重试");
+ const prompt = compilePrevisPrompt(current, activeShot);
+ // 先镜像最新 scene,再做 canvas 输出;失败时 draft 保留可继续重试。
+ const next = touchPrevisScene(current);
+ writeAndPublish(next);
+ await onApply({ scene: next, shot: activeShot, prompt, beauty });
+ message.success("预演台构图已回写画布");
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "预演台输出失败");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const exportClayVideo = async () => {
+ stagedTransaction.end("commit");
+ const current = draftRef.current;
+ if (!current || !activeShot || !viewportRef.current || recording) return;
+ const expected = { scene: current, shotId: activeShot.id };
+ setRecording(true);
+ const wasPlaying = playing;
+ const previousPlayhead = playhead;
+ setPlayhead(0);
+ setPlaying(true);
+ try {
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
+ const recordedVideo = await viewportRef.current.recordVideo(activeShot.duration, activeShot.fps);
+ const clayVideo = recordedVideo.type === "video/mp4" ? recordedVideo : await transcodeVideoToMp4(recordedVideo);
+ if (!isPrevisOutputSnapshotCurrent(draftRef.current, expected)) throw new Error("录制期间场景或镜头已变化,请重试");
+ const next = touchPrevisScene(draftRef.current || current);
+ writeAndPublish(next);
+ const beauty = await viewportRef.current.capture("beauty");
+ if (!isPrevisOutputSnapshotCurrent(draftRef.current, { scene: next, shotId: expected.shotId })) throw new Error("输出期间场景或镜头已变化,请重试");
+ await onApply({ scene: next, shot: activeShot, prompt: compilePrevisPrompt(next, activeShot), beauty, clayVideo, clayVideoMimeType: clayVideo.type });
+ setLastClayExport({ shotId: expected.shotId, at: Date.now() });
+ message.success("白膜 MP4 视频已回写画布");
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "白膜视频导出失败");
+ } finally {
+ setPlaying(wasPlaying);
+ setPlayhead(previousPlayhead);
+ setRecording(false);
+ }
+ };
+
+ const sceneActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || [];
+ const sceneProps = draft?.objects.filter((item) => item.kind !== "actor" && item.primitive !== "character") || [];
+ const normalizedSceneQuery = sceneQuery.trim().toLowerCase();
+ const matchesSceneQuery = (name: string, meta = "") => !normalizedSceneQuery || `${name} ${meta}`.toLowerCase().includes(normalizedSceneQuery);
+ const visibleSceneActors = sceneActors.filter((actor) => matchesSceneQuery(actor.name, `${actor.archetype || ""} ${actor.pose || ""}`));
+ const visibleSceneProps = sceneProps.filter((object) => matchesSceneQuery(object.name, `${object.kind} ${object.primitive || ""}`));
+ const visibleCameras = draft?.cameras.filter((camera) => matchesSceneQuery(camera.name, `${camera.focalLength}mm`)) || [];
+ const visibleLights = draft?.lights.filter((light) => matchesSceneQuery(light.name, light.type)) || [];
+ const selectedActor = sceneActors.find((item) => item.id === selectedObjectId) ?? null;
+ const handleGroundClick = useCallback((point: PrevisGroundPoint) => {
+ if (!selectedActor || !selectedObjectId) {
+ message.info("先在左侧列表选中一个演员,再点击地面落位");
+ return;
+ }
+ setSelectedObjectId(selectedActor.id);
+ handleObjectTransform(selectedActor.id, selectedActor.transform, {
+ ...selectedActor.transform,
+ position: [point.x, selectedActor.transform.position[1], point.z],
+ });
+ message.success("演员已落位");
+ }, [handleObjectTransform, message, selectedActor]);
+ const addActorToScene = () => {
+ addActor();
+ message.success("演员已加入;直接点击视口地面即可落位");
+ };
+
+ const trajectoryTarget = selectedActor ? { kind: "actor" as const, id: selectedActor.id, name: selectedActor.name } : selectedCamera ? { kind: "camera" as const, id: selectedCamera.id, name: selectedCamera.name } : null;
+ const trajectoryPreviousViewModeRef = useRef(viewMode);
+ const trajectorySessionRef = useRef(false);
+ useEffect(() => {
+ if (trajectoryDrawing && !trajectorySessionRef.current) {
+ trajectorySessionRef.current = true;
+ trajectoryPreviousViewModeRef.current = viewMode;
+ if (viewMode !== "top") setViewMode("top");
+ message.info(trajectoryTarget ? `${trajectoryTarget.kind === "camera" ? "为摄影机绘制运镜路径" : "为角色绘制走位"}:按住地面拖动,松开完成,重新点击可取消` : "已进入画轨迹模式:请先在左侧选择演员或摄影机,再按住地面拖动");
+ return;
+ }
+ if (!trajectoryDrawing && trajectorySessionRef.current) {
+ trajectorySessionRef.current = false;
+ setViewMode(trajectoryPreviousViewModeRef.current);
+ }
+ }, [message, setViewMode, trajectoryDrawing, trajectoryTarget, viewMode]);
+ const handleTrajectoryComplete = useCallback((points: PrevisGroundPoint[]) => {
+ const current = draftRef.current;
+ const shot = current?.shots.find((item) => item.id === current.activeShotId) || current?.shots[0];
+ if (!current || !shot || points.length < 2) {
+ setTrajectoryDrawing(false);
+ if (points.length >= 2 && !trajectoryTarget) message.info("轨迹已绘制,但还没有选中的演员或摄影机;请先选择目标再重试");
+ return;
+ }
+ if (selectedActor) {
+ const y = selectedActor.transform.position[1];
+ const path = [[selectedActor.transform.position[0], y, selectedActor.transform.position[2]] as PrevisVec3, ...points.map((point) => [point.x, y, point.z] as PrevisVec3)];
+ const keyframes = buildPrevisPathKeyframes({ base: selectedActor.transform, points: path, duration: shot.duration, orientToPath: true });
+ commit((scene) => ({ ...scene, objects: scene.objects.map((object) => object.id === selectedActor.id ? { ...object, keyframes, motionPath: { points: path, speed: "walk", startDelay: 0, orientToPath: true } } : object) }));
+ message.success("演员轨迹已写入时间轴");
+ } else if (selectedCamera) {
+ const y = selectedCamera.transform.position[1];
+ const path = [[selectedCamera.transform.position[0], y, selectedCamera.transform.position[2]] as PrevisVec3, ...points.map((point) => [point.x, y, point.z] as PrevisVec3)];
+ const keyframes = buildPrevisPathKeyframes({ base: selectedCamera.transform, points: path, duration: shot.duration });
+ commit((scene) => ({ ...scene, cameras: scene.cameras.map((camera) => camera.id === selectedCamera.id ? { ...camera, keyframes, motionPath: { points: path, speed: "walk", startDelay: 0, orientToPath: false } } : camera) }));
+ message.success("摄影机运镜路径已写入时间轴");
+ }
+ setTrajectoryDrawing(false);
+ }, [commit, message, selectedActor, selectedCamera, trajectoryTarget]);
+
+ // ── 演员体型模板 ──────────────────────────────────────────────────────────
+ /** 每个模板:name 显示名、archetype、体型 overrides(可覆盖 previsActorProfileForArchetype 默认值)*/
+ const ACTOR_TEMPLATES: Array<{ key: string; label: string; archetype: import("@/types/previs").PrevisActorArchetype; profileOverrides?: Partial; crowd?: boolean }> = [
+ { key: "std-male", label: "标准男性", archetype: "man" },
+ { key: "std-female", label: "标准女性", archetype: "woman" },
+ { key: "muscular", label: "健硕", archetype: "man", profileOverrides: { shoulderWidth: 1.32, torsoRatio: 1.12, height: 1.06 } },
+ { key: "slim", label: "纤细", archetype: "woman", profileOverrides: { shoulderWidth: 0.78, torsoRatio: 0.88, height: 0.98 } },
+ { key: "teen", label: "少年", archetype: "child", profileOverrides: { height: 0.86, headRatio: 1.12 } },
+ { key: "child", label: "儿童", archetype: "child" },
+ { key: "stocky", label: "宽厚", archetype: "man", profileOverrides: { shoulderWidth: 1.18, torsoRatio: 1.24, height: 0.96 } },
+ { key: "chibi", label: "二头身", archetype: "adult", profileOverrides: { height: 0.62, headRatio: 1.68 } },
+ ];
+
+ const addActorFromTemplate = (template: typeof ACTOR_TEMPLATES[number]) => {
+ const existingActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || [];
+ const colorIndex = existingActors.length % PREVIS_ACTOR_COLORS.length;
+ const baseProfile = previsActorProfileForArchetype(template.archetype);
+ const actorProfile = template.profileOverrides ? { ...baseProfile, ...template.profileOverrides } : baseProfile;
+ const actor = { ...createPrevisActor(`${template.label} ${existingActors.length + 1}`, [0, 0, 0], PREVIS_ACTOR_COLORS[colorIndex], template.archetype), actorProfile };
+ addObject(actor);
+ message.success(`已添加${template.label}`);
+ };
+
+ const addCrowdActors = () => {
+ const existingActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || [];
+ const base = existingActors.length;
+ for (let row = 0; row < 3; row++) {
+ for (let col = 0; col < 3; col++) {
+ const idx = base + row * 3 + col;
+ const colorIndex = idx % PREVIS_ACTOR_COLORS.length;
+ const actor = createPrevisActor(`群众 ${idx + 1}`, [col * 1.1 - 1.1, 0, row * 1.1 - 1.1], PREVIS_ACTOR_COLORS[colorIndex]);
+ addObject(actor);
+ }
+ }
+ message.success("已添加 3×3 群众阵列");
+ };
+
+ const actorTemplateMenuItems: MenuProps["items"] = [
+ { key: "upload", icon: , label: "本地上传", onClick: () => modelInputRef.current?.click() },
+ { type: "divider" },
+ ...ACTOR_TEMPLATES.map((template) => ({
+ key: template.key,
+ icon: ,
+ label: template.label,
+ onClick: () => addActorFromTemplate(template),
+ })),
+ { type: "divider" },
+ { key: "crowd", icon: , label: "群众 (3×3)", onClick: addCrowdActors },
+ {
+ key: "geometry",
+ icon: ,
+ label: "几何模型",
+ children: [
+ { key: "geo-upload", icon: , label: "上传文件", onClick: () => modelInputRef.current?.click() },
+ { key: "geo-box", icon: , label: "立方体", onClick: () => addPrimitive("box", "立方体") },
+ { key: "geo-sphere", icon: , label: "球体", onClick: () => addPrimitive("sphere", "球体") },
+ { key: "geo-cyl", icon: , label: "圆柱体", onClick: () => addPrimitive("cylinder", "圆柱体") },
+ { key: "geo-torus", icon: , label: "环状体", onClick: () => addPrimitive("sphere", "环状体") },
+ { key: "geo-cone", icon: , label: "圆锥", onClick: () => addPrimitive("plane", "圆锥") },
+ { key: "geo-plane", icon: , label: "平面", onClick: () => addPrimitive("plane", "平面") },
+ { key: "geo-null", icon: , label: "添加空对象", onClick: () => addPrimitive("box", "空对象") },
+ ],
+ },
+ ];
+
+ // ── 机位预设 ────────────────────────────────────────────────────────────────
+ type CameraPreset = {
+ key: string;
+ label: string;
+ position: [number, number, number];
+ target: [number, number, number];
+ fov: number;
+ isCurrentView?: boolean;
+ };
+ const CAMERA_PRESETS: CameraPreset[] = [
+ { key: "current", label: "当前视角", position: [4.8, 2.7, 6.8], target: [0, 1, 0], fov: 50, isCurrentView: true },
+ { key: "front-medium", label: "正面中景", position: [0, 1.4, 4.0], target: [0, 1, 0], fov: 50 },
+ { key: "front-cu", label: "正面特写", position: [0, 1.65, 1.8], target: [0, 1.6, 0], fov: 40 },
+ { key: "front-wide", label: "正面全景", position: [0, 2.0, 7.5], target: [0, 1, 0], fov: 60 },
+ { key: "side-follow", label: "侧面跟拍", position: [3.8, 1.4, 0 ], target: [0, 1, 0], fov: 50 },
+ { key: "side-cu", label: "侧面近景", position: [2.0, 1.65, 0 ], target: [0, 1.6, 0], fov: 45 },
+ { key: "back-medium", label: "背面中景", position: [0, 1.4, -4.0], target: [0, 1, 0], fov: 50 },
+ { key: "bird-wide", label: "俯拍全景", position: [0, 6.5, 3.5], target: [0, 0, 0], fov: 65 },
+ { key: "angle-45", label: "45° 俯拍", position: [3.0, 4.5, 4.5], target: [0, 0.8, 0], fov: 55 },
+ { key: "low-uptilt", label: "低角度仰拍", position: [0, 0.3, 3.5], target: [0, 2.2, 0], fov: 55 },
+ { key: "low-wide", label: "低角度广角", position: [0, 0.5, 4.5], target: [0, 1.2, 0], fov: 75 },
+ { key: "ots-left", label: "过肩镜头", position: [-1.1, 1.75, 2.2], target: [0.6, 1.65, 0], fov: 50 },
+ { key: "ots-right", label: "过肩镜头(右)",position: [1.1, 1.75, 2.2], target: [-0.6, 1.65, 0], fov: 50 },
+ { key: "birds-eye", label: "鸟瞰", position: [0, 9.0, 0.1], target: [0, 0, 0], fov: 70 },
+ { key: "dutch", label: "荷兰角", position: [2.8, 1.7, 4.0], target: [0, 1.2, 0], fov: 52 },
+ ];
+
+ const addCameraPreset = (preset: CameraPreset) => {
+ if (preset.isCurrentView) {
+ const transform = viewportRef.current?.readCameraTransform();
+ if (transform) {
+ const cam = { ...createPrevisCamera(`机位 ${(draft?.cameras.length || 0) + 1}`), transform, target: preset.target };
+ commit((current) => ({ ...current, cameras: [...current.cameras, cam] }));
+ if (activeShot) updateShot(activeShot.id, { cameraId: cam.id });
+ setViewMode("camera");
+ message.success("已从当前视角创建机位");
+ } else {
+ addCamera();
+ message.info("已添加默认机位");
+ }
+ return;
+ }
+ const cam = {
+ ...createPrevisCamera(`机位 ${(draft?.cameras.length || 0) + 1}`),
+ transform: { position: preset.position, rotation: [0, 0, 0] as [number, number, number], scale: [1, 1, 1] as [number, number, number] },
+ target: preset.target,
+ fov: preset.fov,
+ };
+ commit((current) => ({ ...current, cameras: [...current.cameras, cam] }));
+ if (activeShot) updateShot(activeShot.id, { cameraId: cam.id });
+ setViewMode("camera");
+ message.success(`已添加「${preset.label}」机位`);
+ };
+
+ const cameraPresetMenuItems: MenuProps["items"] = CAMERA_PRESETS.map((preset) => ({
+ key: preset.key,
+ icon: ,
+ label: preset.label,
+ onClick: () => addCameraPreset(preset),
+ }));
+
+ // ── 道具库 (语义化尺寸,单位米) ─────────────────────────────────────────
+ const PROP_LIBRARY: Array<{ label: string; items: Array<{ name: string; size: string; primitive: "box" | "sphere" | "cylinder" | "plane"; scale: [number, number, number] }> }> = [
+ { label: "家具", items: [
+ { name: "单人沙发", size: "0.9×0.85×0.85m", primitive: "box", scale: [0.9, 0.85, 0.85] },
+ { name: "三人沙发", size: "2.2×0.85×0.85m", primitive: "box", scale: [2.2, 0.85, 0.85] },
+ { name: "餐桌", size: "1.6×0.75×0.9m", primitive: "box", scale: [1.6, 0.75, 0.9] },
+ { name: "茶几", size: "1.0×0.45×0.6m", primitive: "box", scale: [1.0, 0.45, 0.6] },
+ { name: "凳子", size: "0.4×0.45×0.4m", primitive: "box", scale: [0.4, 0.45, 0.4] },
+ { name: "双人床", size: "1.8×0.5×2.1m", primitive: "box", scale: [1.8, 0.5, 2.1] },
+ ]},
+ { label: "空间结构", items: [
+ { name: "墙板", size: "3×2.8×0.2m", primitive: "box", scale: [3, 2.8, 0.2] },
+ { name: "门", size: "0.9×2.1×0.1m", primitive: "box", scale: [0.9, 2.1, 0.1] },
+ { name: "窗框", size: "1.2×1.4×0.1m", primitive: "box", scale: [1.2, 1.4, 0.1] },
+ { name: "地面板", size: "4×0.1×4m", primitive: "box", scale: [4, 0.1, 4] },
+ { name: "台阶", size: "1×0.2×0.3m", primitive: "box", scale: [1, 0.2, 0.3] },
+ ]},
+ { label: "自然/外景", items: [
+ { name: "山体", size: "8×4×6m", primitive: "box", scale: [8, 4, 6] },
+ { name: "岩石", size: "1.5×1×1.2m", primitive: "sphere", scale: [1.5, 1, 1.2] },
+ { name: "树干", size: "0.3×3×0.3m", primitive: "cylinder", scale: [0.3, 3, 0.3] },
+ { name: "地面背板", size: "6×0.05×6m", primitive: "plane", scale: [6, 0.05, 6] },
+ ]},
+ { label: "道具", items: [
+ { name: "汽车轮廓", size: "4.5×1.5×2m", primitive: "box", scale: [4.5, 1.5, 2] },
+ { name: "柱子", size: "0.4×3×0.4m", primitive: "cylinder", scale: [0.4, 3, 0.4] },
+ { name: "箱子", size: "0.6×0.6×0.6m", primitive: "box", scale: [0.6, 0.6, 0.6] },
+ { name: "球体道具", size: "0.5m直径", primitive: "sphere", scale: [0.5, 0.5, 0.5] },
+ ]},
+ ];
+
+ const enterAnimationMode = useCallback(() => {
+ stagedTransaction.end("commit");
+ if (selectedActor) {
+ setSelectedObjectId(selectedActor.id);
+ setSelectedLightId(null);
+ setSelectedBone(null);
+ }
+ setPlaying(false);
+ setSequencerVisible(true);
+ }, [selectedActor, setPlaying, setSelectedBone, setSelectedLightId, setSelectedObjectId, setSequencerVisible, stagedTransaction]);
+
+ useEffect(() => {
+ const onPreviewRequested = (event: Event) => {
+ const detail = (event as CustomEvent).detail;
+ if (!isPrevisPreviewRequestForWorkbench({ request: detail, canvasId: effectiveCanvasId, sceneId: draft?.id, activeShotId: activeShot?.id })) return;
+ const requestKey = previsPreviewRequestKey(detail);
+ if (handledPreviewRequestsRef.current.has(requestKey)) return;
+ // 标记发生在 recording 判断之前:录制期间的重复/并发事件也不能在录制结束后再次执行。
+ handledPreviewRequestsRef.current.add(requestKey);
+ if (handledPreviewRequestsRef.current.size > 64) {
+ const oldest = handledPreviewRequestsRef.current.values().next().value;
+ if (typeof oldest === "string") handledPreviewRequestsRef.current.delete(oldest);
+ }
+ if (recording) return;
+ void exportClayVideo();
+ };
+ window.addEventListener("previs:preview-requested", onPreviewRequested);
+ return () => window.removeEventListener("previs:preview-requested", onPreviewRequested);
+ }, [activeShot?.id, draft?.id, effectiveCanvasId, exportClayVideo]);
+
+ if (!open || !draft || !activeShot) return null;
+
+ const saveStatusClass = saveIndicator.tone === "danger" ? "is-danger" : saveIndicator.busy ? "is-busy" : "";
+ // 位于 early return 之后,不能使用 Hook;每次渲染直接派生主题变量。
+ const workbenchThemeStyle = {
+ "--pd-s0": theme.canvas.background,
+ "--pd-s1": theme.toolbar.panel,
+ "--pd-s2": theme.toolbar.activeBg,
+ "--pd-s3": theme.toolbar.itemHover,
+ "--pd-s4": theme.toolbar.activeBg,
+ "--pd-s5": theme.spatial.elevated,
+ "--pd-b0": theme.toolbar.border,
+ "--pd-b1": theme.node.stroke,
+ "--pd-b2": theme.node.activeStroke,
+ "--pd-t0": theme.node.text,
+ "--pd-t1": theme.node.muted,
+ "--pd-t2": theme.node.muted,
+ "--pd-t3": theme.node.faint,
+ "--pd-a0": theme.accent.primary,
+ "--pd-a1": theme.accent.primary,
+ "--pd-a-bg": theme.accent.primarySoft,
+ "--pd-a-bd": theme.node.activeStroke,
+ "--pd-a-on": theme.accent.onPrimary,
+ "--pd-accent-rgb": theme.accent.primary === "#171717" || theme.accent.primary === "#f5f5f5" ? "255 255 255" : "79 140 255",
+ "--previs-accent": theme.accent.primary,
+ "--pd-a-on-theme": theme.accent.onPrimary,
+ "--pd-a-bg-theme": theme.accent.primarySoft,
+ "--pd-a-bd-theme": theme.node.activeStroke,
+ } as CSSProperties;
+
+ return (
+
+
+
+
+ {/* LEFT: Scene Entity List */}
+
+
+ {/* CENTER: Single viewport with contextual tool layer */}
+
+ object.keyframes.length > 1) || draft.cameras.some((camera) => camera.keyframes.length > 1)}
+ trajectoryDrawing={trajectoryDrawing}
+ onTrajectoryComplete={handleTrajectoryComplete}
+ viewMode={viewMode}
+ onSelectObject={setSelectedObjectId}
+ onSelectBone={setSelectedBone}
+ onGroundClick={handleGroundClick}
+ onObjectTransform={handleObjectTransform}
+ onBoneTransform={handleBoneTransform}
+ onActorRigReady={handleActorRigReady}
+ selectedCameraId={selectedCameraId}
+ onSelectCamera={(id) => { setSelectedCameraId(id); setSelectedObjectId(null); setSelectedLightId(null); setViewMode("camera"); }}
+ onCameraTransform={handleCameraTransform}
+ showModelLoadNotice
+ showNavigation={false}
+ />
+ {viewMode === "camera" && (selectedCamera || activeCamera) ? (
+
+
+ {(selectedCamera || activeCamera)?.name}
+ {(selectedCamera || activeCamera)?.focalLength}mm
+
+ ) : null}
+ {trajectoryDrawing ? {trajectoryTarget ? <>正在为「{trajectoryTarget.name}」绘制{trajectoryTarget.kind === "camera" ? "运镜路径" : "走位"}按住地面拖动,松开完成 · 再点按钮取消> : <>请先选择演员或摄影机左侧列表选择目标后,再按住地面拖动绘制轨迹>}
: null}
+ setTrajectoryDrawing((active) => !active)}
+ viewMode={viewMode}
+ onViewModeChange={setViewMode}
+ cameras={draft.cameras}
+ viewedCameraId={selectedCameraId || activeShot.cameraId || null}
+ onViewCamera={(id) => { setSelectedCameraId(id); setSelectedObjectId(null); setSelectedLightId(null); setViewMode("camera"); }}
+ onFocusSelected={() => viewportRef.current?.focusSelected()}
+ onFrameScene={() => viewportRef.current?.frameScene()}
+ onZoom={(factor) => viewportRef.current?.zoom(factor)}
+ timelineOpen={sequencerVisible}
+ onOpenTimeline={enterAnimationMode}
+ scenePanelOpen={compactLayout ? scenePanelOpen : sceneDocked}
+ onToggleScenePanel={() => compactLayout ? setScenePanelOpen((value) => !value) : setSceneDocked((value) => !value)}
+ inspectorOpen={compactLayout ? inspectorOpen : inspectorDocked}
+ onToggleInspector={() => compactLayout ? setInspectorOpen((value) => !value) : setInspectorDocked((value) => !value)}
+ />
+
+
+ {/* RIGHT: Shot settings + output */}
+
+
+ {sequencerVisible ? (
+
+ setPlaying(!playing)} onPlayheadChange={setPlayhead} onAutoKeyChange={setAutoKey} onHeightChange={setSequencerHeight} onVisibilityChange={setSequencerVisible} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onRecordKeyframe={recordSelectedKeyframe} onAddShot={addShot} onDeleteKeyframe={deleteKeyframe} onSetKeyframeEasing={setKeyframeEasing} onSelectShot={(id) => { commit((current) => ({ ...current, activeShotId: id })); setPlayhead(0); }} />
+
+ ) : null}
+
+
+ );
+}
+
+/** 渲染视图全集。实际可选项由当前模式的 capabilities.renderModes 过滤。 */
+const PREVIS_RENDER_MODE_LABELS: Array<{ label: string; value: PrevisRenderMode }> = [
+ { label: "预览", value: "beauty" },
+ { label: "彩色白膜", value: "clay" },
+ { label: "骨骼", value: "pose" },
+ { label: "深度", value: "depth" },
+ { label: "法线", value: "normal" },
+];
+
+function cameraMoveTransform(transform: PrevisTransform, move: PrevisCameraMove): PrevisTransform {
+ const [x, y, z] = transform.position;
+ const offsets: Record = { static: [0, 0, 0], push_in: [0, 0, -2], pull_out: [0, 0, 2], pan_left: [-2, 0, 0], pan_right: [2, 0, 0], tilt_up: [0, 1.5, 0], tilt_down: [0, -1.2, 0], orbit_left: [-2.5, 0, -1.5], orbit_right: [2.5, 0, -1.5], handheld: [0.18, 0.08, -0.15] };
+ const offset = offsets[move];
+ return { ...transform, position: [x + offset[0], y + offset[1], z + offset[2]] };
+}
diff --git a/web/src/components/canvas/previs/previs-canvas-dock.tsx b/web/src/components/canvas/previs/previs-canvas-dock.tsx
new file mode 100644
index 000000000..8f315b458
--- /dev/null
+++ b/web/src/components/canvas/previs/previs-canvas-dock.tsx
@@ -0,0 +1,175 @@
+import { AnimatePresence, motion } from "motion/react";
+import { useEffect, useRef, useState } from "react";
+import { Camera, Check, Focus, Minus, Move3d, PanelLeft, Plus, Rotate3d, Route, Scale3d, Scan, SlidersHorizontal, WandSparkles } from "lucide-react";
+
+import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
+import { aceternityMotion } from "@/lib/aceternity-motion";
+import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
+import type { CanvasTheme } from "@/lib/canvas-theme";
+import { PREVIS_VIEW_MODES, type PrevisViewMode } from "@/lib/canvas/previs/previs-view-modes";
+import type { PrevisCamera } from "@/types/previs";
+
+type TransformMode = "translate" | "rotate" | "scale";
+
+export type PrevisCanvasDockProps = {
+ theme: CanvasTheme;
+ transformMode: TransformMode;
+ onTransformModeChange: (mode: TransformMode) => void;
+ trajectoryDrawing: boolean;
+ trajectoryKind: "actor" | "camera" | null;
+ onToggleTrajectory: () => void;
+ viewMode: PrevisViewMode;
+ onViewModeChange: (mode: PrevisViewMode) => void;
+ cameras: PrevisCamera[];
+ viewedCameraId: string | null;
+ onViewCamera: (id: string) => void;
+ onFocusSelected: () => void;
+ onFrameScene: () => void;
+ onZoom: (factor: number) => void;
+ timelineOpen: boolean;
+ onOpenTimeline: () => void;
+ scenePanelOpen: boolean;
+ onToggleScenePanel: () => void;
+ inspectorOpen: boolean;
+ onToggleInspector: () => void;
+};
+
+/**
+ * 预演台视口工具条:与画布主工具条共用 FloatingDock 组件、dock token 与提示/动效。
+ * 一条 dock 承载「改内容」(变换、画轨迹) 与「怎么看」(取景、导航),取景细项收进弹层,
+ * 避免视口四角各挂一组浮层遮挡画面。
+ */
+export function PrevisCanvasDock(props: PrevisCanvasDockProps) {
+ const { theme } = props;
+ const rootRef = useRef(null);
+ const [viewMenuOpen, setViewMenuOpen] = useState(false);
+
+ useEffect(() => {
+ if (!viewMenuOpen) return;
+ const close = (event: PointerEvent) => {
+ if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setViewMenuOpen(false);
+ };
+ const closeOnEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setViewMenuOpen(false);
+ };
+ document.addEventListener("pointerdown", close, true);
+ document.addEventListener("keydown", closeOnEscape);
+ return () => {
+ document.removeEventListener("pointerdown", close, true);
+ document.removeEventListener("keydown", closeOnEscape);
+ };
+ }, [viewMenuOpen]);
+
+ const currentView = PREVIS_VIEW_MODES.find((item) => item.mode === props.viewMode) ?? PREVIS_VIEW_MODES[0];
+ const trajectoryLabel = props.trajectoryDrawing ? "结束绘制轨迹" : props.trajectoryKind === "camera" ? "画运镜路径" : "画走位";
+
+ const items: FloatingDockEntry[] = [
+ { id: "previs-scene-panel", label: props.scenePanelOpen ? "收起场景面板" : "打开场景面板", icon: , active: props.scenePanelOpen, expands: true, onClick: props.onToggleScenePanel },
+ { kind: "separator", id: "previs-sep-scene" },
+ {
+ kind: "switch",
+ id: "previs-transform",
+ label: "变换模式",
+ value: props.transformMode,
+ onChange: (value) => props.onTransformModeChange(value as TransformMode),
+ options: [
+ { id: "previs-translate", value: "translate", label: "移动对象 (W)", icon: },
+ { id: "previs-rotate", value: "rotate", label: "旋转对象 (E)", icon: },
+ { id: "previs-scale", value: "scale", label: "缩放对象 (R)", icon: },
+ ],
+ },
+ { id: "previs-trajectory", label: trajectoryLabel, icon: , active: props.trajectoryDrawing, onClick: props.onToggleTrajectory },
+ { kind: "separator", id: "previs-sep-view" },
+ {
+ id: "previs-view-mode",
+ label: `取景:${currentView.label}`,
+ wide: true,
+ quiet: true,
+ expands: true,
+ active: viewMenuOpen,
+ icon: {currentView.label},
+ onClick: () => setViewMenuOpen((value) => !value),
+ },
+ { id: "previs-focus", label: "聚焦选中对象", icon: , onClick: props.onFocusSelected },
+ { id: "previs-frame", label: "适配全部对象", icon: , onClick: props.onFrameScene },
+ { id: "previs-zoom-out", label: "拉远视角", icon: , onClick: () => props.onZoom(1.38) },
+ { id: "previs-zoom-in", label: "拉近视角", icon: , onClick: () => props.onZoom(0.72) },
+ { kind: "separator", id: "previs-sep-panels" },
+ { id: "previs-timeline", label: "动画时间轴", icon: , active: props.timelineOpen, onClick: props.onOpenTimeline },
+ { id: "previs-inspector", label: props.inspectorOpen ? "收起属性面板" : "打开属性面板", icon: , active: props.inspectorOpen, expands: true, onClick: props.onToggleInspector },
+ ];
+
+ return (
+ event.stopPropagation()} onWheel={(event) => event.stopPropagation()}>
+
+ {viewMenuOpen ? (
+
+
+ 取景
+ 只改变观察方式,不写入场景
+
+
+ {PREVIS_VIEW_MODES.map((item) => {
+ const checked = item.mode === props.viewMode;
+ return (
+
+ );
+ })}
+
+ {props.cameras.length ? (
+ <>
+ 机位
+
+ {props.cameras.map((camera) => {
+ const current = props.viewMode === "camera" && camera.id === props.viewedCameraId;
+ return (
+
+ );
+ })}
+
+ >
+ ) : null}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/web/src/components/canvas/director/director-inspectors.tsx b/web/src/components/canvas/previs/previs-inspectors.tsx
similarity index 66%
rename from web/src/components/canvas/director/director-inspectors.tsx
rename to web/src/components/canvas/previs/previs-inspectors.tsx
index b2311767c..a4c9284d1 100644
--- a/web/src/components/canvas/director/director-inspectors.tsx
+++ b/web/src/components/canvas/previs/previs-inspectors.tsx
@@ -1,30 +1,57 @@
-// 导演台右侧检查器:物体、灯光、镜头、变换与骨骼旋转字段,以及场景列表的小控件。
+// 预演台右侧检查器:物体、灯光、镜头、变换与骨骼旋转字段,以及场景列表的小控件。
import {
- type DirectorCamera,
- type DirectorCameraMove,
- type DirectorHumanoidBone,
- type DirectorLight,
- type DirectorObject,
- type DirectorPose,
- type DirectorQuat,
- type DirectorScene,
- type DirectorShot,
- type DirectorShotSize,
- type DirectorTransform,
- type DirectorVec3,
-} from "@/types/director";
-import { type DirectorModeCapabilities } from "@/lib/canvas/director/director-modes";
-import { DIRECTOR_ACTOR_COLORS, directorBoneLabel, directorFocalLengthToFov, directorPoseLabel } from "@/lib/canvas/director/director-scene";
+ type PrevisAspectRatio,
+ type PrevisCamera,
+ type PrevisCameraMove,
+ type PrevisHumanoidBone,
+ type PrevisLight,
+ type PrevisObject,
+ type PrevisActorArchetype,
+ type PrevisPose,
+ type PrevisQuat,
+ type PrevisScene,
+ type PrevisShot,
+ type PrevisShotSize,
+ type PrevisTransform,
+ type PrevisVec3,
+} from "@/types/previs";
+import { type PrevisModeCapabilities } from "@/lib/canvas/previs/previs-modes";
+import { PREVIS_ACTOR_ARCHETYPES, PREVIS_ACTOR_COLORS, previsActorArchetypeLabel, previsActorProfileForArchetype, previsBoneLabel, previsFocalLengthToFov, previsPoseLabel } from "@/lib/canvas/previs/previs-scene";
import { Button, ColorPicker, Dropdown, Input, InputNumber, type MenuProps, Slider } from "antd";
import { Select } from "@/components/ui/base/select";
import { Switch } from "@/components/ui/base/switch";
-import { Camera, Focus, Plus, Trash2, Video } from "lucide-react";
+import { Camera, Copy, Focus, Plus, Trash2, Video } from "lucide-react";
import { type ReactElement, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { Euler, Quaternion } from "three";
-import { releaseDirectorFocusAfterPointer } from "@/lib/canvas/director/director-shortcuts";
+import { releasePrevisFocusAfterPointer } from "@/lib/canvas/previs/previs-shortcuts";
-export const poseOptions: Array<{ label: string; value: DirectorPose }> = [
+const PREVIS_HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
+
+function normalizePrevisHexColor(value: string, fallback = "#ffffff") {
+ const trimmed = value.trim().toLowerCase();
+ if (!PREVIS_HEX_COLOR_PATTERN.test(trimmed)) return fallback;
+ if (trimmed.length === 4) return `#${trimmed.slice(1).split("").map((part) => part + part).join("")}`;
+ return trimmed;
+}
+
+function HexColorInput({ value, onChange, label = "十六进制颜色" }: { value: string; onChange: (value: string) => void; label?: string }) {
+ const normalized = normalizePrevisHexColor(value);
+ const [draft, setDraft] = useState(normalized);
+ useEffect(() => setDraft(normalized), [normalized]);
+ const commit = () => {
+ const next = normalizePrevisHexColor(draft, "");
+ if (!next) {
+ setDraft(normalized);
+ return;
+ }
+ setDraft(next);
+ onChange(next);
+ };
+ return setDraft(event.target.value)} onBlur={commit} onPressEnter={commit} placeholder="#RRGGBB" />;
+}
+
+export const poseOptions: Array<{ label: string; value: PrevisPose }> = [
{ label: "站立", value: "stand" },
{ label: "T 型", value: "t_pose" },
{ label: "行走", value: "walk" },
@@ -47,6 +74,8 @@ export const poseOptions: Array<{ label: string; value: DirectorPose }> = [
{ label: "看手机", value: "phone" },
];
+export const poseEditBones: PrevisHumanoidBone[] = ["head", "spine", "leftUpperArm", "leftLowerArm", "rightUpperArm", "rightLowerArm", "leftUpperLeg", "rightUpperLeg"];
+
export const shotSizeOptions = [
{ label: "大远景", value: "extreme_wide" },
{ label: "远景", value: "wide" },
@@ -81,28 +110,32 @@ export function ObjectInspector({
onBoneRotationStage,
onBoneRotationCommit,
onAddKeyframe,
+ onDuplicate,
onDelete,
}: {
- object: DirectorObject;
- rendered: DirectorTransform;
+ object: PrevisObject;
+ rendered: PrevisTransform;
playhead: number;
selectedBone: string | null;
- capabilities: DirectorModeCapabilities;
+ capabilities: PrevisModeCapabilities;
onSelectBone: (bone: string | null) => void;
- onUpdate: (patch: Partial) => void;
- onTransformEdit: (transform: DirectorTransform) => void;
- onBoneRotationStage: (rotation: DirectorQuat) => void;
+ onUpdate: (patch: Partial) => void;
+ onTransformEdit: (transform: PrevisTransform) => void;
+ onBoneRotationStage: (rotation: PrevisQuat) => void;
onBoneRotationCommit: () => void;
onAddKeyframe: () => void;
+ onDuplicate: () => void;
onDelete: () => void;
}) {
const motionClips = object.motionClips || [];
const activeMotionClip = motionClips.find((clip) => clip.id === object.activeMotionClipId);
- const mappedBones = Object.keys(object.rig?.boneMap || {}) as DirectorHumanoidBone[];
- const selectedBoneId = selectedBone as DirectorHumanoidBone | null;
- const selectedBoneRotation = selectedBoneId ? object.boneOverrides?.[selectedBoneId] || ([0, 0, 0, 1] as DirectorQuat) : null;
- const updateActiveMotion = (patch: Partial[number]>) => activeMotionClip && onUpdate({ motionClips: motionClips.map((clip) => (clip.id === activeMotionClip.id ? { ...clip, ...patch } : clip)) });
- const applyPose = (pose: DirectorPose) => onUpdate({ pose, activeMotionClipId: undefined, boneOverrides: {} });
+ const isActor = object.kind === "actor" || object.primitive === "character";
+ const mappedBones = Object.keys(object.rig?.boneMap || {}) as PrevisHumanoidBone[];
+ const poseBones = mappedBones.length ? mappedBones : poseEditBones;
+ const selectedBoneId = selectedBone as PrevisHumanoidBone | null;
+ const selectedBoneRotation = selectedBoneId ? object.boneOverrides?.[selectedBoneId] || ([0, 0, 0, 1] as PrevisQuat) : null;
+ const updateActiveMotion = (patch: Partial[number]>) => activeMotionClip && onUpdate({ motionClips: motionClips.map((clip) => (clip.id === activeMotionClip.id ? { ...clip, ...patch } : clip)) });
+ const applyPose = (pose: PrevisPose) => onUpdate({ pose, activeMotionClipId: undefined, boneOverrides: {} });
const resetSelectedBone = () => {
if (!selectedBoneId) return;
const boneOverrides = { ...(object.boneOverrides || {}) };
@@ -110,27 +143,30 @@ export function ObjectInspector({
onUpdate({ boneOverrides });
};
return (
- onUpdate({ name })} onDelete={onDelete}>
-
- {object.kind === "actor" || object.primitive === "character" ? (
-
-
- {DIRECTOR_ACTOR_COLORS.map((color) => (
-
-
+ onUpdate({ name })} onDuplicate={onDuplicate} onDelete={onDelete}>
+
+ {isActor ? (
+ <>
+
+
+
+
+ {PREVIS_ACTOR_COLORS.map((color) =>
+
+ onUpdate({ color })} label="角色十六进制颜色" />
+
+
+ >
) : (
- onUpdate({ color })} />
+
+ onUpdate({ color: color.toHexString() })} />
+ onUpdate({ color })} />
+
)}
{/*
@@ -140,14 +176,14 @@ export function ObjectInspector({
*/}
{capabilities.bones && (object.kind === "actor" || object.primitive === "character") ? (
<>
-
-
+
+
姿势预设
- {directorPoseLabel(object.pose || "stand")}
+ {previsPoseLabel(object.pose || "stand")}
-
+
{poseOptions.map((option) => (
-