Skip to content

feat: add playback speed control and visualization for video segments #506

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
235 changes: 235 additions & 0 deletions apps/desktop/src/routes/editor/ConfigSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ const TAB_IDS = {
transcript: "transcript",
audio: "audio",
cursor: "cursor",
clips: "clips",
hotkeys: "hotkeys",
} as const;

Expand All @@ -214,6 +215,7 @@ export function ConfigSidebar() {
| "transcript"
| "audio"
| "cursor"
| "clips"
| "hotkeys",
});

Expand Down Expand Up @@ -247,6 +249,7 @@ export function ConfigSidebar() {
meta().type === "multiple" && (meta() as any).segments[0].cursor
),
},
{ id: TAB_IDS.clips, icon: IconCapScissors },
// { id: "hotkeys" as const, icon: IconCapHotkeys },
]}
>
Expand Down Expand Up @@ -417,6 +420,7 @@ export function ConfigSidebar() {
</Field>
)}
</KTabs.Content>
<ClipConfig scrollRef={scrollRef} />
<KTabs.Content value="cursor" class="flex flex-col gap-6">
<Field
name="Cursor"
Expand Down Expand Up @@ -1883,6 +1887,237 @@ function ZoomSegmentConfig(props: {
);
}

function ClipConfig(props: { scrollRef: HTMLDivElement }) {
const { project, setProject, editorState, setEditorState } =
useEditorContext();

const SPEED_PRESETS = [
{ label: "0.25x", value: 4 },
{ label: "0.5x", value: 2 },
{ label: "0.75x", value: 1.33 },
{ label: "1x (Normal)", value: 1 },
{ label: "1.25x", value: 0.8 },
{ label: "1.5x", value: 0.67 },
{ label: "2x", value: 0.5 },
{ label: "3x", value: 0.33 },
{ label: "4x", value: 0.25 },
];

const segments = () => project.timeline?.segments ?? [];
const selectedSegmentIndex = () => editorState.selectedClipIndex;

const hasSelectedSegment = () =>
selectedSegmentIndex() !== null &&
segments().length > 0 &&
typeof selectedSegmentIndex() === "number" &&
selectedSegmentIndex()! >= 0 &&
selectedSegmentIndex()! < segments().length;

const selectedSegment = () =>
hasSelectedSegment() ? segments()[selectedSegmentIndex()!] : null;

return (
<KTabs.Content value="clips" class="flex flex-col gap-6">
<Field name="Clip Settings" icon={<IconCapScissors />}>
<Show
when={segments().length > 0}
fallback={
<div class="text-gray-500 text-center py-4">
No clips available. Split your recording to create clips.
</div>
}
>
<div class="flex flex-col gap-4">
<Subfield name="Select Clip" required>
<KSelect
options={segments().map((_, index) => ({
label: `Clip ${index + 1}`,
value: index,
}))}
optionValue="value"
optionTextValue="label"
value={
hasSelectedSegment()
? {
label: `Clip ${selectedSegmentIndex()! + 1}`,
value: selectedSegmentIndex()!,
}
: undefined
}
onChange={(selected) => {
if (selected) {
setEditorState("selectedClipIndex", selected.value);
}
}}
placeholder="Select a clip"
itemComponent={(props) => (
<MenuItem<typeof KSelect.Item>
as={KSelect.Item}
item={props.item}
>
<KSelect.ItemLabel class="flex-1">
{props.item.rawValue.label}
</KSelect.ItemLabel>
</MenuItem>
)}
>
<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">
<KSelect.Value<{
label: string;
value: number;
}> class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal">
{(state) =>
state.selectedOption() ? (
<span>{state.selectedOption().label}</span>
) : (
<span class="text-gray-400">Select clip</span>
)
}
</KSelect.Value>
<KSelect.Icon<ValidComponent>
as={(props) => (
<IconCapChevronDown
{...props}
class="size-4 shrink-0 transform transition-transform ui-expanded:rotate-180 text-[--gray-500]"
/>
)}
/>
</KSelect.Trigger>
<KSelect.Portal>
<PopperContent<typeof KSelect.Content>
as={KSelect.Content}
class={cx(topSlideAnimateClasses, "z-50")}
>
<MenuItemList<typeof KSelect.Listbox>
class="overflow-y-auto max-h-32"
as={KSelect.Listbox}
/>
</PopperContent>
</KSelect.Portal>
</KSelect>
</Subfield>

<Show when={hasSelectedSegment() && selectedSegment()}>
<Subfield name="Playback Speed" required>
<KSelect
options={SPEED_PRESETS}
optionValue="value"
optionTextValue="label"
value={
SPEED_PRESETS.find(
(preset) =>
Math.abs(preset.value - selectedSegment()!.timescale) <
0.01
) || {
label: `${(1 / selectedSegment()!.timescale).toFixed(
2
)}x`,
value: selectedSegment()!.timescale,
}
}
onChange={(selected) => {
if (selected) {
setProject(
"timeline",
"segments",
selectedSegmentIndex()!,
"timescale",
selected.value
);
}
}}
itemComponent={(props) => (
<MenuItem<typeof KSelect.Item>
as={KSelect.Item}
item={props.item}
>
<KSelect.ItemLabel class="flex-1">
{props.item.rawValue.label}
</KSelect.ItemLabel>
</MenuItem>
)}
>
<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">
<KSelect.Value<{
label: string;
value: number;
}> class="flex-1 text-sm text-left truncate text-[--gray-500] font-normal">
{(state) => <span>{state.selectedOption().label}</span>}
</KSelect.Value>
<KSelect.Icon<ValidComponent>
as={(props) => (
<IconCapChevronDown
{...props}
class="size-4 shrink-0 transform transition-transform ui-expanded:rotate-180 text-[--gray-500]"
/>
)}
/>
</KSelect.Trigger>
<KSelect.Portal>
<PopperContent<typeof KSelect.Content>
as={KSelect.Content}
class={cx(topSlideAnimateClasses, "z-50")}
>
<MenuItemList<typeof KSelect.Listbox>
class="overflow-y-auto max-h-32"
as={KSelect.Listbox}
/>
</PopperContent>
</KSelect.Portal>
</KSelect>
</Subfield>

<Subfield name="Custom Speed">
<div class="flex items-center gap-2">
<input
type="number"
min="0.1"
max="10"
step="0.1"
value={(1 / selectedSegment()!.timescale).toFixed(2)}
onInput={(e) => {
const value = parseFloat(e.currentTarget.value);
if (!isNaN(value) && value > 0) {
setProject(
"timeline",
"segments",
selectedSegmentIndex()!,
"timescale",
1 / value
);
}
}}
class="w-20 px-2 py-1 rounded border border-gray-300 bg-white"
/>
<span class="text-gray-500">x</span>
</div>
</Subfield>

<div class="mt-2">
<p class="text-xs text-gray-500">
Original duration:{" "}
{(selectedSegment()!.end - selectedSegment()!.start).toFixed(
2
)}
s
</p>
<p class="text-xs text-gray-500">
Playback duration:{" "}
{(
(selectedSegment()!.end - selectedSegment()!.start) /
selectedSegment()!.timescale
).toFixed(2)}
s
</p>
</div>
</Show>
</div>
</Show>
</Field>
</KTabs.Content>
);
}

function RgbInput(props: {
value: [number, number, number];
onChange: (value: [number, number, number]) => void;
Expand Down
36 changes: 36 additions & 0 deletions apps/desktop/src/routes/editor/Player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,31 @@ export function Player() {
}
});

const currentSegment = () => {
if (!project.timeline?.segments?.length) return null;

let currentTime = 0;
for (const segment of project.timeline.segments) {
const segmentDuration = (segment.end - segment.start) / segment.timescale;
if (
editorState.playbackTime >= currentTime &&
editorState.playbackTime < currentTime + segmentDuration
) {
return segment;
}
currentTime += segmentDuration;
}

return null;
};

const currentSpeed = () => {
const segment = currentSegment();
return segment && segment.timescale !== 1
? (1 / segment.timescale).toFixed(2) + "x"
: null;
};

return (
<div class="flex flex-col flex-1 bg-gray-100 dark:bg-gray-100 rounded-xl shadow-sm">
<div class="flex gap-3 justify-center p-3">
Expand All @@ -111,6 +136,12 @@ export function Player() {
>
Crop
</EditorButton>
<Show when={currentSpeed()}>
<div class="flex items-center gap-1 px-3 py-1 bg-blue-500/20 rounded-lg text-gray-600 font-medium">
<IconLucideClock class="size-4 mr-1" />
{currentSpeed()}
</div>
</Show>
</div>
<PreviewCanvas />
<div class="flex z-10 overflow-hidden flex-row gap-3 justify-between items-center p-5">
Expand All @@ -124,6 +155,11 @@ export function Player() {
/>
<span class="text-gray-400 text-[0.875rem] tabular-nums"> / </span>
<Time seconds={totalDuration()} />
<Show when={currentSpeed()}>
<span class="ml-2 text-xs px-2 py-0.5 bg-blue-500/20 rounded-full text-gray-500">
{currentSpeed()}
</span>
</Show>
</div>
<div class="flex flex-row items-center justify-center text-gray-400 gap-8 text-[0.875rem]">
<button
Expand Down
24 changes: 23 additions & 1 deletion apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function ClipTrack(props: Pick<ComponentProps<"div">, "ref">) {
projectHistory,
editorState,
totalDuration,
setEditorState,
} = useEditorContext();

const { secsPerPixel, duration } = useTimelineContext();
Expand All @@ -41,12 +42,25 @@ export function ClipTrack(props: Pick<ComponentProps<"div">, "ref">) {
.slice(0, i())
.reduce((t, s) => t + (s.end - s.start) / s.timescale, 0);

const getSpeedClass = () => {
if (segment.timescale === 1) return "";
if (segment.timescale > 1)
return "bg-stripes-blue border-l-4 border-l-blue-500";
return "bg-stripes-green border-l-4 border-l-green-500";
};

const getSpeedText = () => {
if (segment.timescale === 1) return null;
return (1 / segment.timescale).toFixed(2) + "x";
};

return (
<SegmentRoot
class={cx(
"overflow-hidden border border-transparent transition-colors duration-300 group",
"hover:border-gray-500",
"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)]"
"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)]",
getSpeedClass()
)}
innerClass="ring-blue-300"
segment={{
Expand Down Expand Up @@ -78,6 +92,9 @@ export function ClipTrack(props: Pick<ComponentProps<"div">, "ref">) {
})
);
}}
onClick={() => {
setEditorState("selectedClipIndex", i());
}}
>
<Markings segment={segment} prevDuration={prevDuration()} />

Expand Down Expand Up @@ -159,6 +176,11 @@ export function ClipTrack(props: Pick<ComponentProps<"div">, "ref">) {
<IconLucideClock class="size-3.5" />{" "}
{(segment.end - segment.start).toFixed(1)}s
</div>
<Show when={getSpeedText()}>
<div class="flex gap-1 items-center text-gray-50 dark:text-gray-500 text-md bg-blue-500/30 px-2 py-1 rounded-full mt-1 font-medium shadow-sm">
<IconLucideClock class="size-3" /> {getSpeedText()}
</div>
</Show>
</div>
</Show>
);
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 @@ -153,6 +153,7 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider(
previewTime: null as number | null,
playbackTime: 0,
playing: false,
selectedClipIndex: null as number | null,
timeline: {
interactMode: "seek" as "seek" | "split",
selection: null as null | { type: "zoom"; index: number },
Expand Down
Loading
Loading