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']