Skip to content

Commit d210911

Browse files
committed
feat: add playback speed control and visualization for video segments
1 parent 72af2c1 commit d210911

File tree

4 files changed

+278
-1
lines changed

4 files changed

+278
-1
lines changed

apps/desktop/src/routes/editor/ConfigSidebar.tsx

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ const TAB_IDS = {
200200
transcript: "transcript",
201201
audio: "audio",
202202
cursor: "cursor",
203+
clips: "clips",
203204
hotkeys: "hotkeys",
204205
} as const;
205206

@@ -214,6 +215,7 @@ export function ConfigSidebar() {
214215
| "transcript"
215216
| "audio"
216217
| "cursor"
218+
| "clips"
217219
| "hotkeys",
218220
});
219221

@@ -247,6 +249,7 @@ export function ConfigSidebar() {
247249
meta().type === "multiple" && (meta() as any).segments[0].cursor
248250
),
249251
},
252+
{ id: TAB_IDS.clips, icon: IconCapScissors },
250253
// { id: "hotkeys" as const, icon: IconCapHotkeys },
251254
]}
252255
>
@@ -417,6 +420,7 @@ export function ConfigSidebar() {
417420
</Field>
418421
)}
419422
</KTabs.Content>
423+
<ClipConfig scrollRef={scrollRef} />
420424
<KTabs.Content value="cursor" class="flex flex-col gap-6">
421425
<Field
422426
name="Cursor"
@@ -1883,6 +1887,237 @@ function ZoomSegmentConfig(props: {
18831887
);
18841888
}
18851889

1890+
function ClipConfig(props: { scrollRef: HTMLDivElement }) {
1891+
const { project, setProject, editorState, setEditorState } =
1892+
useEditorContext();
1893+
1894+
const SPEED_PRESETS = [
1895+
{ label: "0.25x", value: 4 },
1896+
{ label: "0.5x", value: 2 },
1897+
{ label: "0.75x", value: 1.33 },
1898+
{ label: "1x (Normal)", value: 1 },
1899+
{ label: "1.25x", value: 0.8 },
1900+
{ label: "1.5x", value: 0.67 },
1901+
{ label: "2x", value: 0.5 },
1902+
{ label: "3x", value: 0.33 },
1903+
{ label: "4x", value: 0.25 },
1904+
];
1905+
1906+
const segments = () => project.timeline?.segments ?? [];
1907+
const selectedSegmentIndex = () => editorState.selectedClipIndex;
1908+
1909+
const hasSelectedSegment = () =>
1910+
selectedSegmentIndex() !== null &&
1911+
segments().length > 0 &&
1912+
typeof selectedSegmentIndex() === "number" &&
1913+
selectedSegmentIndex()! >= 0 &&
1914+
selectedSegmentIndex()! < segments().length;
1915+
1916+
const selectedSegment = () =>
1917+
hasSelectedSegment() ? segments()[selectedSegmentIndex()!] : null;
1918+
1919+
return (
1920+
<KTabs.Content value="clips" class="flex flex-col gap-6">
1921+
<Field name="Clip Settings" icon={<IconCapScissors />}>
1922+
<Show
1923+
when={segments().length > 0}
1924+
fallback={
1925+
<div class="text-gray-500 text-center py-4">
1926+
No clips available. Split your recording to create clips.
1927+
</div>
1928+
}
1929+
>
1930+
<div class="flex flex-col gap-4">
1931+
<Subfield name="Select Clip" required>
1932+
<KSelect
1933+
options={segments().map((_, index) => ({
1934+
label: `Clip ${index + 1}`,
1935+
value: index,
1936+
}))}
1937+
optionValue="value"
1938+
optionTextValue="label"
1939+
value={
1940+
hasSelectedSegment()
1941+
? {
1942+
label: `Clip ${selectedSegmentIndex()! + 1}`,
1943+
value: selectedSegmentIndex()!,
1944+
}
1945+
: undefined
1946+
}
1947+
onChange={(selected) => {
1948+
if (selected) {
1949+
setEditorState("selectedClipIndex", selected.value);
1950+
}
1951+
}}
1952+
placeholder="Select a clip"
1953+
itemComponent={(props) => (
1954+
<MenuItem<typeof KSelect.Item>
1955+
as={KSelect.Item}
1956+
item={props.item}
1957+
>
1958+
<KSelect.ItemLabel class="flex-1">
1959+
{props.item.rawValue.label}
1960+
</KSelect.ItemLabel>
1961+
</MenuItem>
1962+
)}
1963+
>
1964+
<KSelect.Trigger class="flex flex-row gap-2 items-center px-2 w-full h-8 bg-gray-200 rounded-lg transition-colors disabled:text-gray-400">
1965+
<KSelect.Value<{
1966+
label: string;
1967+
value: number;
1968+
}> class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal">
1969+
{(state) =>
1970+
state.selectedOption() ? (
1971+
<span>{state.selectedOption().label}</span>
1972+
) : (
1973+
<span class="text-gray-400">Select clip</span>
1974+
)
1975+
}
1976+
</KSelect.Value>
1977+
<KSelect.Icon<ValidComponent>
1978+
as={(props) => (
1979+
<IconCapChevronDown
1980+
{...props}
1981+
class="size-4 shrink-0 transform transition-transform ui-expanded:rotate-180 text-[--gray-500]"
1982+
/>
1983+
)}
1984+
/>
1985+
</KSelect.Trigger>
1986+
<KSelect.Portal>
1987+
<PopperContent<typeof KSelect.Content>
1988+
as={KSelect.Content}
1989+
class={cx(topSlideAnimateClasses, "z-50")}
1990+
>
1991+
<MenuItemList<typeof KSelect.Listbox>
1992+
class="overflow-y-auto max-h-32"
1993+
as={KSelect.Listbox}
1994+
/>
1995+
</PopperContent>
1996+
</KSelect.Portal>
1997+
</KSelect>
1998+
</Subfield>
1999+
2000+
<Show when={hasSelectedSegment() && selectedSegment()}>
2001+
<Subfield name="Playback Speed" required>
2002+
<KSelect
2003+
options={SPEED_PRESETS}
2004+
optionValue="value"
2005+
optionTextValue="label"
2006+
value={
2007+
SPEED_PRESETS.find(
2008+
(preset) =>
2009+
Math.abs(preset.value - selectedSegment()!.timescale) <
2010+
0.01
2011+
) || {
2012+
label: `${(1 / selectedSegment()!.timescale).toFixed(
2013+
2
2014+
)}x`,
2015+
value: selectedSegment()!.timescale,
2016+
}
2017+
}
2018+
onChange={(selected) => {
2019+
if (selected) {
2020+
setProject(
2021+
"timeline",
2022+
"segments",
2023+
selectedSegmentIndex()!,
2024+
"timescale",
2025+
selected.value
2026+
);
2027+
}
2028+
}}
2029+
itemComponent={(props) => (
2030+
<MenuItem<typeof KSelect.Item>
2031+
as={KSelect.Item}
2032+
item={props.item}
2033+
>
2034+
<KSelect.ItemLabel class="flex-1">
2035+
{props.item.rawValue.label}
2036+
</KSelect.ItemLabel>
2037+
</MenuItem>
2038+
)}
2039+
>
2040+
<KSelect.Trigger class="flex flex-row gap-2 items-center px-2 w-full h-8 bg-gray-200 rounded-lg transition-colors disabled:text-gray-400">
2041+
<KSelect.Value<{
2042+
label: string;
2043+
value: number;
2044+
}> class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal">
2045+
{(state) => <span>{state.selectedOption().label}</span>}
2046+
</KSelect.Value>
2047+
<KSelect.Icon<ValidComponent>
2048+
as={(props) => (
2049+
<IconCapChevronDown
2050+
{...props}
2051+
class="size-4 shrink-0 transform transition-transform ui-expanded:rotate-180 text-[--gray-500]"
2052+
/>
2053+
)}
2054+
/>
2055+
</KSelect.Trigger>
2056+
<KSelect.Portal>
2057+
<PopperContent<typeof KSelect.Content>
2058+
as={KSelect.Content}
2059+
class={cx(topSlideAnimateClasses, "z-50")}
2060+
>
2061+
<MenuItemList<typeof KSelect.Listbox>
2062+
class="overflow-y-auto max-h-32"
2063+
as={KSelect.Listbox}
2064+
/>
2065+
</PopperContent>
2066+
</KSelect.Portal>
2067+
</KSelect>
2068+
</Subfield>
2069+
2070+
<Subfield name="Custom Speed">
2071+
<div class="flex items-center gap-2">
2072+
<input
2073+
type="number"
2074+
min="0.1"
2075+
max="10"
2076+
step="0.1"
2077+
value={(1 / selectedSegment()!.timescale).toFixed(2)}
2078+
onInput={(e) => {
2079+
const value = parseFloat(e.currentTarget.value);
2080+
if (!isNaN(value) && value > 0) {
2081+
setProject(
2082+
"timeline",
2083+
"segments",
2084+
selectedSegmentIndex()!,
2085+
"timescale",
2086+
1 / value
2087+
);
2088+
}
2089+
}}
2090+
class="w-20 px-2 py-1 rounded border border-gray-300 bg-white"
2091+
/>
2092+
<span class="text-gray-500">x</span>
2093+
</div>
2094+
</Subfield>
2095+
2096+
<div class="mt-2">
2097+
<p class="text-xs text-gray-500">
2098+
Original duration:{" "}
2099+
{(selectedSegment()!.end - selectedSegment()!.start).toFixed(
2100+
2
2101+
)}
2102+
s
2103+
</p>
2104+
<p class="text-xs text-gray-500">
2105+
Playback duration:{" "}
2106+
{(
2107+
(selectedSegment()!.end - selectedSegment()!.start) /
2108+
selectedSegment()!.timescale
2109+
).toFixed(2)}
2110+
s
2111+
</p>
2112+
</div>
2113+
</Show>
2114+
</div>
2115+
</Show>
2116+
</Field>
2117+
</KTabs.Content>
2118+
);
2119+
}
2120+
18862121
function RgbInput(props: {
18872122
value: [number, number, number];
18882123
onChange: (value: [number, number, number]) => void;

apps/desktop/src/routes/editor/Player.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,31 @@ export function Player() {
8686
}
8787
});
8888

89+
const currentSegment = () => {
90+
if (!project.timeline?.segments?.length) return null;
91+
92+
let currentTime = 0;
93+
for (const segment of project.timeline.segments) {
94+
const segmentDuration = (segment.end - segment.start) / segment.timescale;
95+
if (
96+
editorState.playbackTime >= currentTime &&
97+
editorState.playbackTime < currentTime + segmentDuration
98+
) {
99+
return segment;
100+
}
101+
currentTime += segmentDuration;
102+
}
103+
104+
return null;
105+
};
106+
107+
const currentSpeed = () => {
108+
const segment = currentSegment();
109+
return segment && segment.timescale !== 1
110+
? (1 / segment.timescale).toFixed(2) + "x"
111+
: null;
112+
};
113+
89114
return (
90115
<div class="flex flex-col flex-1 bg-gray-100 dark:bg-gray-100 rounded-xl shadow-sm">
91116
<div class="flex gap-3 justify-center p-3">
@@ -124,6 +149,11 @@ export function Player() {
124149
/>
125150
<span class="text-gray-400 text-[0.875rem] tabular-nums"> / </span>
126151
<Time seconds={totalDuration()} />
152+
<Show when={currentSpeed()}>
153+
<span class="ml-2 text-xs px-2 py-0.5 bg-blue-500/20 rounded-full text-gray-500">
154+
{currentSpeed()}
155+
</span>
156+
</Show>
127157
</div>
128158
<div class="flex flex-row items-center justify-center text-gray-400 gap-8 text-[0.875rem]">
129159
<button

apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export function ClipTrack(props: Pick<ComponentProps<"div">, "ref">) {
1717
projectHistory,
1818
editorState,
1919
totalDuration,
20+
setEditorState,
2021
} = useEditorContext();
2122

2223
const { secsPerPixel, duration } = useTimelineContext();
@@ -46,7 +47,8 @@ export function ClipTrack(props: Pick<ComponentProps<"div">, "ref">) {
4647
class={cx(
4748
"overflow-hidden border border-transparent transition-colors duration-300 group",
4849
"hover:border-gray-500",
49-
"bg-gradient-to-r timeline-gradient-border from-[#2675DB] via-[#4FA0FF] to-[#2675DB] shadow-[inset_0_5px_10px_5px_rgba(255,255,255,0.2)]"
50+
"bg-gradient-to-r timeline-gradient-border from-[#2675DB] via-[#4FA0FF] to-[#2675DB] shadow-[inset_0_5px_10px_5px_rgba(255,255,255,0.2)]",
51+
segment.timescale !== 1 && "bg-stripes-blue"
5052
)}
5153
innerClass="ring-blue-300"
5254
segment={{
@@ -78,6 +80,9 @@ export function ClipTrack(props: Pick<ComponentProps<"div">, "ref">) {
7880
})
7981
);
8082
}}
83+
onClick={() => {
84+
setEditorState("selectedClipIndex", i());
85+
}}
8186
>
8287
<Markings segment={segment} prevDuration={prevDuration()} />
8388

@@ -159,6 +164,12 @@ export function ClipTrack(props: Pick<ComponentProps<"div">, "ref">) {
159164
<IconLucideClock class="size-3.5" />{" "}
160165
{(segment.end - segment.start).toFixed(1)}s
161166
</div>
167+
<Show when={segment.timescale !== 1}>
168+
<div class="flex gap-1 items-center text-gray-50 dark:text-gray-500 text-md bg-blue-500/20 px-2 py-0.5 rounded-full mt-1">
169+
<IconLucideClock class="size-3" />{" "}
170+
{(1 / segment.timescale).toFixed(2)}x
171+
</div>
172+
</Show>
162173
</div>
163174
</Show>
164175
);

apps/desktop/src/routes/editor/context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider(
153153
previewTime: null as number | null,
154154
playbackTime: 0,
155155
playing: false,
156+
selectedClipIndex: null as number | null,
156157
timeline: {
157158
interactMode: "seek" as "seek" | "split",
158159
selection: null as null | { type: "zoom"; index: number },

0 commit comments

Comments
 (0)