diff --git a/apps/desktop/src/routes/editor/Timeline/Track.tsx b/apps/desktop/src/routes/editor/Timeline/Track.tsx index 0953269d2b7..62cc1388fd3 100644 --- a/apps/desktop/src/routes/editor/Timeline/Track.tsx +++ b/apps/desktop/src/routes/editor/Timeline/Track.tsx @@ -59,6 +59,7 @@ export function SegmentRoot( onMouseDown?: ( e: MouseEvent & { currentTarget: HTMLDivElement; target: Element }, ) => void; + overflowVisible?: boolean; }, ) { const { editorState } = useEditorContext(); @@ -84,7 +85,8 @@ export function SegmentRoot( >
diff --git a/apps/desktop/src/routes/editor/Timeline/TrackManager.tsx b/apps/desktop/src/routes/editor/Timeline/TrackManager.tsx index 8ece70cf03f..2f50c1883f2 100644 --- a/apps/desktop/src/routes/editor/Timeline/TrackManager.tsx +++ b/apps/desktop/src/routes/editor/Timeline/TrackManager.tsx @@ -1,7 +1,7 @@ import { LogicalPosition } from "@tauri-apps/api/dpi"; import { CheckMenuItem, Menu, MenuItem } from "@tauri-apps/api/menu"; import { cx } from "cva"; -import type { JSX } from "solid-js"; +import { Show, type JSX } from "solid-js"; import type { TimelineTrackType } from "../context"; type TrackManagerOption = { @@ -76,16 +76,26 @@ export function TrackManager(props: { ); } -export function TrackIcon(props: { icon: JSX.Element; class?: string }) { +export function TrackIcon(props: { icon: JSX.Element; class?: string; onClick?: () => void; subordinate?: boolean }) { return (
e.stopPropagation()} > - {props.icon} + + {/* Tree connector L shape */} +
+
+ +
{props.icon}
); } diff --git a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx index de8fb4808e2..ee0fc49425b 100644 --- a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx @@ -13,6 +13,7 @@ import { Match, Show, Switch, + For, } from "solid-js"; import { produce } from "solid-js/store"; import { commands } from "~/utils/tauri"; @@ -519,6 +520,7 @@ export function ZoomTrack(props: { return ( +
@@ -745,3 +747,170 @@ export function ZoomTrack(props: { ); } + +export function ZoomCurveTrack() { + const { project, editorState } = useEditorContext(); + const { secsPerPixel } = useTimelineContext(); + + const zoomSegments = () => project.timeline?.zoomSegments ?? []; + + return ( + +
+
+ +
+ + {(segment, i) => { + const base = () => editorState.timeline.transform.position; + const translateX = () => (segment.start - base()) / secsPerPixel(); + const width = () => (segment.end - segment.start) / secsPerPixel(); + + return ( +
+ {(() => { + const isInstant = () => segment.instantAnimation; + + const prev = () => i() > 0 ? zoomSegments()[i() - 1] : null; + const next = () => zoomSegments()[i() + 1]; + + const gapBeforeSecs = () => prev() ? segment.start - prev()!.end : Infinity; + const gapAfterSecs = () => next() ? next()!.start - segment.end : Infinity; + + const isContiguousWithPrev = () => gapBeforeSecs() <= 0.001; + const isContiguousWithNext = () => gapAfterSecs() <= 0.001; + + const rampDurationSecs = 1.0; + + const prevAmt = () => { + const p = prev(); + if (!p) return 1.0; + if (isContiguousWithPrev()) return p.amount; + if (gapBeforeSecs() >= rampDurationSecs) return 1.0; + const t = gapBeforeSecs() / rampDurationSecs; + return p.amount + (1.0 - p.amount) * t; + }; + + const currAmt = () => segment.amount; + + const nextAmt = () => { + const n = next(); + if (!n) return 1.0; + if (isContiguousWithNext()) return n.amount; + if (gapAfterSecs() >= rampDurationSecs) return 1.0; + const t = gapAfterSecs() / rampDurationSecs; + return segment.amount + (1.0 - segment.amount) * t; + }; + + // Map amount to Y coordinate linearly (1.0 -> 90, 4.5 -> 5) + const getY = (amt: number) => { + const minAmt = 1.0; + const maxAmt = 4.5; + const minY = 90; + const maxY = 5; + const clampedAmt = Math.min(maxAmt, Math.max(minAmt, amt)); + return minY - ((clampedAmt - minAmt) / (maxAmt - minAmt)) * (minY - maxY); + }; + + const startY = () => getY(prevAmt()); + const currY = () => getY(currAmt()); + const endY = () => getY(nextAmt()); + + const W = () => Math.max(1, width()); + const rampPixels = () => rampDurationSecs / secsPerPixel(); + + const toPct = (px: number) => (px / W()) * 100; + const rampUpPct = () => isInstant() ? 0 : toPct(Math.min(rampPixels(), W() / 2)); + const rampDownPct = () => isInstant() ? 0 : toPct(rampPixels()); + + const actualRampDownPct = () => isInstant() ? 0 : toPct(Math.min(rampDurationSecs, gapAfterSecs()) / secsPerPixel()); + + const dGray = () => { + let parts = []; + + // 1. Gap before + if (i() === 0) { + parts.push(`M -100000 ${getY(1.0)} L 0 ${getY(1.0)}`); + } else if (gapBeforeSecs() >= rampDurationSecs) { + const prevSeg = prev(); + if (prevSeg) { + const prevEndOffset = (prevSeg.end - segment.start) / secsPerPixel(); + const gapStartX = prevSeg.instantAnimation ? prevEndOffset : prevEndOffset + rampPixels(); + if (gapStartX < 0) { + parts.push(`M ${toPct(gapStartX)} ${getY(1.0)} L 0 ${getY(1.0)}`); + } + } + } + + // 2. Flat top + parts.push(`M ${rampUpPct()} ${currY()} L 100 ${currY()}`); + + // 3. Gap after (if last) + if (i() === zoomSegments().length - 1) { + const afterXPct = 100 + (!isContiguousWithNext() ? rampDownPct() : 0); + parts.push(`M ${afterXPct} ${getY(1.0)} L 100000 ${getY(1.0)}`); + } + + return parts.join(" "); + }; + + const dColored = () => { + let parts = []; + + // 1. Ramp Up + if (isInstant()) { + parts.push(`M 0 ${startY()} L 0 ${currY()}`); + } else { + parts.push(`M 0 ${startY()} C ${rampUpPct() / 2} ${startY()}, ${rampUpPct() / 2} ${currY()}, ${rampUpPct()} ${currY()}`); + } + + // 2. Ramp Down + if (!isContiguousWithNext()) { + if (isInstant()) { + parts.push(`M 100 ${currY()} L 100 ${endY()}`); + } else { + parts.push(`M 100 ${currY()} C ${100 + actualRampDownPct() / 2} ${currY()}, ${100 + actualRampDownPct() / 2} ${endY()}, ${100 + actualRampDownPct()} ${endY()}`); + } + } + + return parts.join(" "); + }; + + return ( + + + + + ); + })()} +
+ ); + }} +
+
+ + ); +} + diff --git a/apps/desktop/src/routes/editor/Timeline/index.tsx b/apps/desktop/src/routes/editor/Timeline/index.tsx index 379d207ba2b..65841224aa6 100644 --- a/apps/desktop/src/routes/editor/Timeline/index.tsx +++ b/apps/desktop/src/routes/editor/Timeline/index.tsx @@ -45,7 +45,7 @@ import { type MaskSegmentDragState, MaskTrack } from "./MaskTrack"; import { type SceneSegmentDragState, SceneTrack } from "./SceneTrack"; import { type TextSegmentDragState, TextTrack } from "./TextTrack"; import { TrackIcon, TrackManager } from "./TrackManager"; -import { type ZoomSegmentDragState, ZoomTrack } from "./ZoomTrack"; +import { type ZoomSegmentDragState, ZoomTrack, ZoomCurveTrack } from "./ZoomTrack"; const TIMELINE_PADDING = 16; const TRACK_GUTTER = 64; @@ -980,7 +980,15 @@ export function Timeline(props: { )} - + setEditorState("timeline", "showZoomCurves", (v) => !v)} + iconOverlay={() => + editorState.timeline.showZoomCurves ? + : + + } + > { zoomSegmentDragState = v; @@ -988,6 +996,19 @@ export function Timeline(props: { handleUpdatePlayhead={handleUpdatePlayhead} /> +
+ } + subordinate={true} + > + + +
void; onContextMenu?: (e: MouseEvent) => void; + onIconClick?: () => void; + iconOverlay?: () => JSX.Element; + subordinate?: boolean; + class?: string; }) { return (
-
+
@@ -1039,6 +1068,19 @@ function TrackRow(props: { + + +
{props.children} diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index d85e2be6eeb..cab13fcdcbc 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -792,6 +792,7 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( staleDismissed: false, }, timeline: { + showZoomCurves: true, interactMode: "seek" as "seek" | "split", selection: null as | null diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index f30b9df566b..54db6ad95d6 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -63,6 +63,7 @@ declare global { const IconCapZoomIn: typeof import('~icons/cap/zoom-in.jsx')['default'] const IconCapZoomOut: typeof import('~icons/cap/zoom-out.jsx')['default'] const IconHugeiconsEaseCurveControlPoints: typeof import('~icons/hugeicons/ease-curve-control-points.jsx')['default'] + const IconLucideActivity: typeof import('~icons/lucide/activity.jsx')['default'] const IconLucideAlertCircle: typeof import('~icons/lucide/alert-circle.jsx')['default'] const IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle.jsx')['default'] const IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left.jsx')['default'] @@ -77,6 +78,7 @@ declare global { const IconLucideClock: typeof import('~icons/lucide/clock.jsx')['default'] const IconLucideColumns2: typeof import('~icons/lucide/columns2.jsx')['default'] const IconLucideEdit: typeof import('~icons/lucide/edit.jsx')['default'] + const IconLucideExpand: typeof import('~icons/lucide/expand.jsx')['default'] const IconLucideEyeOff: typeof import('~icons/lucide/eye-off.jsx')['default'] const IconLucideFastForward: typeof import('~icons/lucide/fast-forward.jsx')['default'] const IconLucideFolder: typeof import('~icons/lucide/folder.jsx')['default'] @@ -107,7 +109,9 @@ declare global { const IconLucideSave: typeof import('~icons/lucide/save.jsx')['default'] const IconLucideSearch: typeof import('~icons/lucide/search.jsx')['default'] const IconLucideSettings: typeof import('~icons/lucide/settings.jsx')['default'] + const IconLucideSettings2: typeof import('~icons/lucide/settings2.jsx')['default'] const IconLucideSparkles: typeof import('~icons/lucide/sparkles.jsx')['default'] + const IconLucideSpline: typeof import('~icons/lucide/spline.jsx')['default'] const IconLucideSquarePlay: typeof import('~icons/lucide/square-play.jsx')['default'] const IconLucideSubtitles: typeof import('~icons/lucide/subtitles.jsx')['default'] const IconLucideType: typeof import('~icons/lucide/type.jsx')['default']