Skip to content
Merged
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
17 changes: 10 additions & 7 deletions electron/native/ScreenCaptureKitRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -394,13 +394,8 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {

func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
guard sessionStarted, sampleBuffer.isValid, isRecording else { return }
guard let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return }

if outputType == .screen {
if frameCount > 0 && CMTimeCompare(presentationTime, lastVideoPresentationTime) <= 0 {
return
}

guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
let attachment = attachments.first,
let statusRawValue = attachment[SCStreamFrameInfo.status] as? Int,
Expand All @@ -413,8 +408,10 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
assetWriter?.status == .writing,
videoInput.isReadyForMoreMediaData else { return }

if firstSampleTime == .zero {
firstSampleTime = sampleBuffer.presentationTimeStamp
// Only a complete frame that the writer can accept may establish time zero.
guard let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return }
if frameCount > 0 && CMTimeCompare(presentationTime, lastVideoPresentationTime) <= 0 {
return
}

lastSampleBuffer = sampleBuffer
Expand All @@ -439,10 +436,16 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
print("Recording started")
fflush(stdout)
}
} else if frameCount == 0 {
// A failed crop/append must not leave an empty interval before frame one.
firstSampleTime = .zero
}
return
}

guard frameCount > 0,
let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return }

if outputType == .audio {
guard let systemAudioInput else { return }
appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput, of: systemAudioWriter, firstSampleTime: &firstSystemAudioSampleTime, lastPresentationTime: &lastSystemAudioPresentationTime, presentationTime: presentationTime)
Expand Down
16 changes: 16 additions & 0 deletions electron/native/ScreenCaptureKitRecorder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,19 @@ describe("ScreenCaptureKitRecorder window capture", () => {
expect(recorderSource).toContain("self.windowCropRect = cropRect");
});
});


describe("ScreenCaptureKitRecorder first frame timing", () => {
const callback = recorderSource.slice(recorderSource.indexOf("func stream(_ stream:"), recorderSource.indexOf("func stream(_ stream:") + 5000);
it("validates a complete frame and writer readiness before setting time zero", () => {
const clock = callback.indexOf("adjustedPresentationTime(for:");
expect(clock).toBeGreaterThan(callback.indexOf("status == .complete"));
expect(clock).toBeGreaterThan(callback.indexOf("videoInput.isReadyForMoreMediaData"));
});
it("resets the origin after a rejected first frame and gates audio on accepted video", () => {
expect(callback).toMatch(/else if frameCount == 0\s*\{[^}]*firstSampleTime = \.zero/);
const audioGuard = callback.indexOf("guard frameCount > 0,");
expect(audioGuard).toBeGreaterThan(0);
expect(audioGuard).toBeLessThan(callback.indexOf("if outputType == .audio"));
});
});
Binary file modified electron/native/bin/darwin-arm64/recordly-screencapturekit-helper
Binary file not shown.
211 changes: 39 additions & 172 deletions src/components/video-editor/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ import {
} from "./types";
import { fromCursorSwaySliderValue, toCursorSwaySliderValue } from "./videoPlayback/cursorSway";
import { isZeroPadding } from "./videoPlayback/layoutUtils";
import { supportsPreviewPlaybackRate } from "./videoPlayback/playbackRate";
import { getPreviewPlaybackRateRange } from "./videoPlayback/playbackRate";
import {
cursorSetAssets,
getCursorStyleSizeMultiplier,
Expand Down Expand Up @@ -523,15 +523,10 @@ interface SettingsPanelProps {
selectedClipId?: string | null;
selectedClipSpeed?: number | null;
selectedClipMuted?: boolean | null;
selectedClipShowSourceAudio?: boolean | null;
hasClipSourceAudio?: boolean;
hasClipAudioOverrides?: boolean;
onResetClipAudio?: () => void;
onClipSpeedChange?: (speed: number) => void;
onClipMutedChange?: (muted: boolean) => void;
onClipShowSourceAudioChange?: (show: boolean) => void;
sourceAudioTrackMeta?: Array<{ id: string; label: string }>;
sourceAudioTrackSettings?: Record<string, { volume: number; normalize: boolean }>;
onSourceAudioTrackVolumeChange?: (id: string, volume: number) => void;
onSourceAudioTrackNormalizeChange?: (id: string, normalize: boolean) => void;
onClipDelete?: (id: string) => void;
selectedAudioId?: string | null;
selectedAudioVolume?: number | null;
Expand Down Expand Up @@ -983,15 +978,10 @@ export function SettingsPanel({
selectedClipId,
selectedClipSpeed,
selectedClipMuted,
selectedClipShowSourceAudio = false,
hasClipSourceAudio = false,
hasClipAudioOverrides = false,
onResetClipAudio,
onClipSpeedChange,
onClipMutedChange,
onClipShowSourceAudioChange,
sourceAudioTrackMeta = [],
sourceAudioTrackSettings = {},
onSourceAudioTrackVolumeChange,
onSourceAudioTrackNormalizeChange,
onClipDelete,
selectedAudioId,
selectedAudioVolume,
Expand Down Expand Up @@ -1103,6 +1093,7 @@ export function SettingsPanel({
const { preference: themePreference, setPreference: setThemePreference } = useTheme();
const isBackgroundPanel = panelMode === "background";
const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []);
const clipSpeedRange = useMemo(getPreviewPlaybackRateRange, []);
const [builtInWallpapers, setBuiltInWallpapers] =
useState<BuiltInWallpaper[]>(BUILT_IN_WALLPAPERS);
const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState<string[]>([]);
Expand Down Expand Up @@ -3020,164 +3011,40 @@ export function SettingsPanel({
);

const clipSectionContent = (
<section className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<SectionLabel>{tSettings("clip.title", "Clip")}</SectionLabel>
{selectedClipSpeed != null && selectedClipSpeed !== 1 && (
<span className="rounded-full bg-[#06b6d4]/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[#06b6d4]">
{selectedClipSpeed}×
</span>
)}
</div>

<div className="flex items-center gap-3">
<SectionLabel>{tSettings("speed.label", "Speed")}</SectionLabel>
</div>
<div className="grid grid-cols-4 gap-1.5">
{[
{ speed: 0.25, label: "0.25×" },
{ speed: 0.5, label: "0.5×" },
{ speed: 0.75, label: "0.75×" },
{ speed: 1, label: "1×" },
{ speed: 1.25, label: "1.25×" },
{ speed: 1.5, label: "1.5×" },
{ speed: 2, label: "2×" },
{ speed: 2.5, label: "2.5×" },
{ speed: 3, label: "3×" },
{ speed: 4, label: "4×" },
{ speed: 5, label: "5×" },
{ speed: 8, label: "8×" },
{ speed: 10, label: "10×" },
{ speed: 15, label: "15×" },
{ speed: 20, label: "20×" },
{ speed: 30, label: "30×" },
].map((option) => {
const isActive = selectedClipSpeed === option.speed;
return (
<Button
key={option.speed}
type="button"
onClick={() => onClipSpeedChange?.(option.speed)}
disabled={!supportsPreviewPlaybackRate(option.speed)}
title={
!supportsPreviewPlaybackRate(option.speed)
? tSettings(
"speed.unsupported",
"Not supported for preview on this device",
)
: undefined
}
className={cn(
"h-auto w-full rounded-lg border px-0.5 py-2 text-center shadow-sm transition-all duration-200 ease-out cursor-pointer",
isActive
? "border-[#06b6d4] bg-[#06b6d4] text-white"
: "border-foreground/5 bg-foreground/5 text-muted-foreground hover:bg-foreground/10 hover:border-foreground/10 hover:text-foreground",
)}
>
<span className="text-[10px] font-semibold">{option.label}</span>
</Button>
);
})}
</div>

<div className="mt-2 flex flex-col gap-2 border-t border-foreground/5 pt-3">
<SectionLabel>{tSettings("audio.title", "Audio")}</SectionLabel>

<div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
<div>
<span className="text-[10px] text-muted-foreground">
{tSettings("clip.mute", "Mute")}
</span>
<p className="text-[9px] text-muted-foreground/50 mt-0.5">
{selectedClipMuted
? tSettings("clip.mutedState", "Audio is muted")
: tSettings("clip.unmutedState", "Audio is playing")}
</p>
</div>
<Switch
checked={selectedClipMuted ?? false}
onCheckedChange={(v) => onClipMutedChange?.(v)}
className="data-[state=checked]:bg-[#06b6d4] scale-75"
/>
</div>
{hasClipSourceAudio && (
<div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
<span className="text-[10px] text-muted-foreground">
{tSettings(
"clip.separateClipFromAudio",
"Separate clip from audio",
)}
</span>
<Switch
checked={selectedClipShowSourceAudio ?? false}
onCheckedChange={(v) => onClipShowSourceAudioChange?.(v)}
className="data-[state=checked]:bg-[#06b6d4] scale-75"
/>
</div>
<section className="flex flex-col gap-3">
<SectionLabel>{tSettings("clip.title", "Clip")}</SectionLabel>
<SliderControl
label={tSettings("speed.label", "Speed")}
value={Math.min(clipSpeedRange.max, Math.max(clipSpeedRange.min, selectedClipSpeed ?? 1))}
defaultValue={1}
min={clipSpeedRange.min}
max={clipSpeedRange.max}
step={0.25}
onChange={(value) => onClipSpeedChange?.(value)}
formatValue={(value) => `${value}×`}
parseInput={(text) => Number.parseFloat(text)}
/>
{selectedClipSpeed != null &&
(selectedClipSpeed < clipSpeedRange.min || selectedClipSpeed > clipSpeedRange.max) && (
<p className="text-[11px] text-muted-foreground" role="status">
{selectedClipSpeed}× — {tSettings("speed.unsupported", "Not supported for preview on this device")}
</p>
)}
</div>

{selectedClipId && hasClipSourceAudio && sourceAudioTrackMeta.length > 0 && (
<div className="mt-1 flex flex-col gap-3">
{sourceAudioTrackMeta.map((track) => {
const settings = sourceAudioTrackSettings[track.id] ?? {
volume: 1,
normalize: false,
};
return (
<div
key={track.id}
className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-[11px] font-medium text-foreground">
{track.label}
</span>
<button
type="button"
onClick={() => {
onSourceAudioTrackVolumeChange?.(track.id, 1);
onSourceAudioTrackNormalizeChange?.(
track.id,
false,
);
}}
className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80"
>
{t("common.actions.reset", "Reset")}
</button>
</div>
<div className="mb-2 flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
<span className="text-[10px] text-muted-foreground">
{tSettings("audio.normalize", "Normalize")}
</span>
<Switch
checked={settings.normalize}
onCheckedChange={(v) =>
onSourceAudioTrackNormalizeChange?.(track.id, v)
}
className="data-[state=checked]:bg-[#06b6d4] scale-75"
/>
</div>
<SliderControl
label={tSettings("audio.volume", "Volume")}
value={settings.volume}
defaultValue={1}
min={0}
max={1}
step={0.01}
onChange={(v) =>
onSourceAudioTrackVolumeChange?.(track.id, v)
}
formatValue={(v) => `${Math.round(v * 100)}%`}
parseInput={(text) =>
parseFloat(text.replace(/%$/, "")) / 100
}
/>
</div>
);
})}
</div>
<label className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-2">
<span className="text-[11px] text-muted-foreground">
{tSettings("clip.mute", "Mute clip")}
</span>
<Switch
checked={selectedClipMuted ?? false}
onCheckedChange={(muted) => onClipMutedChange?.(muted)}
aria-label={tSettings("clip.mute", "Mute clip")}
className="data-[state=checked]:bg-[#06b6d4] scale-75"
/>
</label>
{hasClipAudioOverrides && onResetClipAudio && (
<Button type="button" variant="ghost" onClick={onResetClipAudio}>
{tSettings("clip.resetAudioSettings", "Reset audio settings")}
</Button>
)}
</section>
);
Expand Down
27 changes: 2 additions & 25 deletions src/components/video-editor/SliderControl.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { PointerEvent as ReactPointerEvent } from "react";
import { useCallback, useRef, memo, useEffect } from "react";
import { useCallback, useRef, memo } from "react";
import { cn } from "@/lib/utils";

interface SliderControlProps {
Expand Down Expand Up @@ -39,8 +39,6 @@ export const SliderControl = memo(function SliderControl({
parseInput: _parseInput,
accentColor = "blue",
}: SliderControlProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const valueTextRef = useRef<HTMLSpanElement | null>(null);
const boundsRef = useRef<DOMRect | null>(null);
const requestRef = useRef<number | null>(null);

Expand All @@ -51,13 +49,6 @@ export const SliderControl = memo(function SliderControl({
? "bg-foreground/95 shadow-[0_0_10px_rgba(139,92,246,0.28)]"
: "bg-foreground/95 shadow-[0_0_10px_rgba(37,99,235,0.28)]";

// Sync initial and prop-driven changes to CSS variable
useEffect(() => {
if (rootRef.current) {
rootRef.current.style.setProperty("--slider-pct", String(pct / 100));
}
}, [pct]);

const updateValue = useCallback(
(clientX: number) => {
const bounds = boundsRef.current;
Expand All @@ -69,22 +60,10 @@ export const SliderControl = memo(function SliderControl({
const rawValue = min + normalized * (max - min);
const nextValue = clamp(quantizeToStep(rawValue, min, step), min, max);
const finalValue = Number(nextValue.toFixed(6));
const finalPct = (((finalValue - min) / (max - min || 1)) * 100).toFixed(4);

// Direct DOM update for instant feedback
if (rootRef.current) {
rootRef.current.style.setProperty("--slider-pct", String(Number(finalPct) / 100));
rootRef.current.setAttribute("aria-valuenow", String(finalValue));
rootRef.current.setAttribute("aria-valuetext", formatValue(finalValue));
}
if (valueTextRef.current) {
valueTextRef.current.textContent = formatValue(finalValue);
}

// Notify parent
onChange(finalValue);
},
[max, min, onChange, step, formatValue],
[max, min, onChange, step],
);

const handlePointerDown = useCallback(
Expand Down Expand Up @@ -143,7 +122,6 @@ export const SliderControl = memo(function SliderControl({

return (
<div
ref={rootRef}
role="slider"
tabIndex={0}
aria-label={label}
Expand Down Expand Up @@ -189,7 +167,6 @@ export const SliderControl = memo(function SliderControl({
{label}
</span>
<span
ref={valueTextRef}
className="pointer-events-none relative z-10 pr-3 text-[12px] font-medium tabular-nums text-foreground"
>
{formatValue(value)}
Expand Down
1 change: 0 additions & 1 deletion src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,6 @@ export default function VideoEditor() {
activeEffectSection,
appearance,
timeline,
audio,
zoomCommands,
clipCommands,
audioCommands,
Expand Down
Loading
Loading