Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
6e819e4
feat: add continuous zoom curve visualization with visibility toggle
Jun 6, 2026
6e83547
fix: syntax error in ZoomTrack.tsx
Jun 6, 2026
55bbbc9
fix: allow ZoomTrack segments to be overflow-visible to show ending c…
Jun 6, 2026
ea4fe36
style: use asymptotic curve scaling to prevent high zoom levels from …
Jun 6, 2026
c933ba5
refactor: move Timeline settings to the top right of the Timeline panel
Jun 6, 2026
56385a9
chore: fix TS types in TimelineDropdown
Jun 6, 2026
13956d3
refactor: extract zoom curves to dedicated sub-track toggled by track…
Jun 7, 2026
413ce8c
feat: refine zoom curves to be pure lines without blocks and add expa…
Jun 7, 2026
ad73d97
fix: make zoom curve track move with timeline and add limit lines
Jun 7, 2026
cc0d44b
feat: visually indicate subordinate track relation with tree branch c…
Jun 7, 2026
5a695db
feat: animate curve track collapse and fix alignment
Jun 7, 2026
984ed9f
fix: make curve width reactive so SVG scales correctly with timeline …
Jun 7, 2026
2c92155
fix: adjust ramp down curve to finish inside the zoom segment block
Jun 7, 2026
3c12a48
fix: revert ramp-down bounds and change zoom mapping to be linear
Jun 7, 2026
cdbfe8f
fix: make zoom curve width match actual 1.0s video animation duration
Jun 7, 2026
b10db4e
feat: make zoom curve continuous and color sloped sections
Jun 7, 2026
4fb9501
style: lighten zoom curve colors and update max zoom to 4.5
Jun 7, 2026
495270f
style: increase transparency of gray zoom curve sections
Jun 7, 2026
0d0838d
style: make blue curve color lighter (blue-300)
Jun 7, 2026
ac8179e
style: make blue curve color even lighter (blue-200)
Jun 7, 2026
868b0e4
style: reduce gray curve transparency in dark mode for better visibility
Jun 7, 2026
0be90b5
style: make blue curve glow brightly with drop-shadow
Jun 7, 2026
538147a
style: revert light mode blue to original and keep glow only in dark …
Jun 7, 2026
7b70100
feat: add collapse overlay to zoom curve track icon
Jun 7, 2026
84be3e7
feat: change zoom curve track icon to Expand and remove collapse over…
Jun 7, 2026
c4381b0
fix(ui): restrict track icon overlay hover state to icon container only
Jun 7, 2026
2e57253
fix(ui): gracefully interpolate zooming curves when segments are clos…
Jun 7, 2026
b38d084
fix(ui): restore track row hover scope for delete button, constrain i…
Jun 7, 2026
eb4e310
chore(ui): move Show import to top of file in TrackManager
Jun 7, 2026
af16afe
chore(ui): remove dead inline curve code from ZoomTrack segment blocks
Jun 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/desktop/src/routes/editor/Timeline/Track.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function SegmentRoot(
onMouseDown?: (
e: MouseEvent & { currentTarget: HTMLDivElement; target: Element },
) => void;
overflowVisible?: boolean;
},
) {
const { editorState } = useEditorContext();
Expand All @@ -84,7 +85,8 @@ export function SegmentRoot(
>
<div
class={cx(
"relative h-full flex flex-row rounded-xl overflow-hidden group",
"relative h-full flex flex-row rounded-xl group",
!props.overflowVisible && "overflow-hidden",
props.innerClass,
)}
>
Expand Down
18 changes: 14 additions & 4 deletions apps/desktop/src/routes/editor/Timeline/TrackManager.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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 }) {
Comment thread
ivan17lai marked this conversation as resolved.
return (
<div
class={cx(
"relative z-10 w-14 h-13 flex items-center justify-center rounded-xl border border-gray-4/70 bg-gray-2/60 text-gray-12 shadow-[0_4px_16px_-12px_rgba(0,0,0,0.8)] dark:border-gray-4/60 dark:bg-gray-3/40",
"relative z-10 w-14 flex items-center justify-center",
props.subordinate
? "h-full text-gray-11"
: "h-13 rounded-xl border border-gray-4/70 bg-gray-2/60 text-gray-12 shadow-[0_4px_16px_-12px_rgba(0,0,0,0.8)] dark:border-gray-4/60 dark:bg-gray-3/40",
props.onClick ? "cursor-pointer hover:bg-gray-3 dark:hover:bg-gray-4/40 transition-colors" : "",
props.class,
)}
onClick={props.onClick}
onMouseDown={(e) => e.stopPropagation()}
>
{props.icon}
<Show when={props.subordinate}>
{/* Tree connector L shape */}
<div class="absolute left-4 -top-2 w-px h-[calc(50%+8px)] bg-gray-5/70 dark:bg-gray-6/60" />
<div class="absolute left-4 top-1/2 w-3 h-px bg-gray-5/70 dark:bg-gray-6/60" />
</Show>
<div class={cx("relative z-10", props.subordinate && "translate-x-2")}>{props.icon}</div>
</div>
);
}
171 changes: 170 additions & 1 deletion apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Match,
Show,
Switch,
For,
} from "solid-js";
import { produce } from "solid-js/store";
import { commands } from "~/utils/tauri";
Expand Down Expand Up @@ -519,6 +520,7 @@ export function ZoomTrack(props: {

return (
<SegmentRoot
overflowVisible={true}
class={cx(
"border duration-200 hover:border-gray-12 transition-colors group",
"bg-linear-to-r from-[#292929] via-[#434343] to-[#292929] shadow-[inset_0_8px_12px_3px_rgba(255,255,255,0.2)]",
Expand Down Expand Up @@ -640,7 +642,7 @@ export function ZoomTrack(props: {
const ctx = useSegmentContext();

return (
<Switch>
<Switch>
<Match when={ctx.width() < 40}>
<div class="flex justify-center items-center">
<IconLucideSearch class="size-3.5 text-gray-1 dark:text-gray-12" />
Expand Down Expand Up @@ -745,3 +747,170 @@ export function ZoomTrack(props: {
</TrackRoot>
);
}

export function ZoomCurveTrack() {
const { project, editorState } = useEditorContext();
const { secsPerPixel } = useTimelineContext();

const zoomSegments = () => project.timeline?.zoomSegments ?? [];

return (
<TrackRoot>
<div class="absolute inset-x-0 top-[5%] border-t border-gray-500/30 border-dashed" />
<div class="absolute inset-x-0 top-[90%] border-t border-gray-500/30 border-dashed" />

<div class="relative w-full h-full pointer-events-none">
<For each={zoomSegments()}>
{(segment, i) => {
const base = () => editorState.timeline.transform.position;
const translateX = () => (segment.start - base()) / secsPerPixel();
const width = () => (segment.end - segment.start) / secsPerPixel();

return (
<div
class="absolute top-0 bottom-0 overflow-visible"
style={{
transform: `translateX(${translateX()}px)`,
width: `${width()}px`,
}}
>
{(() => {
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 (
<svg
class="absolute inset-0 w-full h-full pointer-events-none overflow-visible z-0"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<path
d={dGray()}
class="stroke-gray-400/30 dark:stroke-gray-500/60"
stroke-width="3"
fill="none"
vector-effect="non-scaling-stroke"
/>
<path
d={dColored()}
class="stroke-blue-500 dark:stroke-blue-400 dark:drop-shadow-[0_0_6px_rgba(96,165,250,0.8)]"
stroke-width="3"
fill="none"
vector-effect="non-scaling-stroke"
/>
</svg>
);
})()}
</div>
);
}}
</For>
</div>
</TrackRoot>
);
}

50 changes: 46 additions & 4 deletions apps/desktop/src/routes/editor/Timeline/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -980,14 +980,35 @@ export function Timeline(props: {
</TrackRow>
)}
</For>
<TrackRow icon={trackIcons.zoom}>
<TrackRow
icon={trackIcons.zoom}
onIconClick={() => setEditorState("timeline", "showZoomCurves", (v) => !v)}
iconOverlay={() =>
editorState.timeline.showZoomCurves ?
<IconLucideChevronUp class="size-5 text-gray-11" /> :
<IconLucideChevronDown class="size-5 text-gray-11" />
}
>
<ZoomTrack
onDragStateChanged={(v) => {
zoomSegmentDragState = v;
}}
handleUpdatePlayhead={handleUpdatePlayhead}
/>
</TrackRow>
<div
class={cx(
"transition-all duration-300 ease-in-out overflow-hidden flex-shrink-0",
editorState.timeline.showZoomCurves ? "h-[3.25rem] opacity-100" : "h-0 opacity-0 -mt-1"
)}
>
<TrackRow
icon={() => <IconLucideSpline class="size-4 text-blue-500" />}
subordinate={true}
>
<ZoomCurveTrack />
</TrackRow>
</div>
<Show when={sceneTrackVisible()}>
<TrackRow icon={trackIcons.scene}>
<SceneTrack
Expand All @@ -1011,18 +1032,26 @@ function TrackRow(props: {
children: JSX.Element;
onDelete?: () => void;
onContextMenu?: (e: MouseEvent) => void;
onIconClick?: () => void;
iconOverlay?: () => JSX.Element;
subordinate?: boolean;
class?: string;
}) {
return (
<div
class="group/track flex items-stretch gap-2"
class={cx("group/track flex items-stretch gap-2", props.class)}
onContextMenu={props.onContextMenu}
>
<div class="relative">
<div class="relative group/icon">
<TrackIcon
icon={props.icon()}
onClick={props.onIconClick}
subordinate={props.subordinate}
class={
props.onDelete
? "transition-opacity group-hover/track:pointer-events-none group-hover/track:opacity-0"
: props.iconOverlay
? "transition-opacity group-hover/icon:pointer-events-none group-hover/icon:opacity-0"
: undefined
}
/>
Expand All @@ -1039,6 +1068,19 @@ function TrackRow(props: {
<IconCapTrash class="size-4" />
</button>
</Show>
<Show when={props.iconOverlay && !props.onDelete}>
<button
class="absolute inset-0 z-20 pointer-events-none flex items-center justify-center rounded-xl border border-gray-4/70 bg-gray-2/90 text-gray-12 opacity-0 transition-opacity group-hover/icon:pointer-events-auto group-hover/icon:opacity-100 dark:border-gray-4/60 dark:bg-gray-3/90 shadow-[0_4px_16px_-12px_rgba(0,0,0,0.8)]"
onClick={(e) => {
e.stopPropagation();
props.onIconClick?.();
}}
onMouseDown={(e) => e.stopPropagation()}
title="Toggle"
>
{props.iconOverlay?.()}
</button>
</Show>
</div>
<div class="flex-1 relative overflow-hidden min-w-0">
{props.children}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/routes/editor/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,7 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider(
staleDismissed: false,
},
timeline: {
showZoomCurves: true,
interactMode: "seek" as "seek" | "split",
selection: null as
| null
Expand Down
4 changes: 4 additions & 0 deletions packages/ui-solid/src/auto-imports.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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']
Expand Down Expand Up @@ -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']
Expand Down