Address review feedback for the video editor refactor

This commit is contained in:
webadderall
2026-09-02 17:06:50 +10:00
parent 85982a1a35
commit cab234d4f7
35 changed files with 214 additions and 169 deletions
@@ -22,6 +22,21 @@ describe("resolveAutoCaptionSourcePath", () => {
).toBe("/Users/test/Desktop/capture.mp4");
});
it("extracts local paths from Recordly media-server URLs", () => {
expect(
resolveAutoCaptionSourcePath({
videoPath:
"http://127.0.0.1:43123/video?path=%2FUsers%2Ftest%2FDesktop%2Fcapture.mp4",
}),
).toBe("/Users/test/Desktop/capture.mp4");
});
it("rejects remote URLs that native captioning cannot read", () => {
expect(
resolveAutoCaptionSourcePath({ videoPath: "https://example.com/capture.mp4" }),
).toBeNull();
});
it("uses session and current video fallbacks only when nothing is loaded", () => {
expect(
resolveAutoCaptionSourcePath({
@@ -1,4 +1,4 @@
import { fromFileUrl } from "./projectPersistence";
import { getLocalFilePathFromResource } from "@/lib/exporter/mediaResource";
type AutoCaptionSourceOptions = {
videoSourcePath?: string | null;
@@ -9,19 +9,19 @@ type AutoCaptionSourceOptions = {
export function resolveAutoCaptionSourcePath(options: AutoCaptionSourceOptions): string | null {
if (options.videoSourcePath) {
return options.videoSourcePath;
return getLocalFilePathFromResource(options.videoSourcePath);
}
if (options.videoPath) {
return fromFileUrl(options.videoPath);
return getLocalFilePathFromResource(options.videoPath);
}
if (options.recordingSessionVideoPath) {
return fromFileUrl(options.recordingSessionVideoPath);
return getLocalFilePathFromResource(options.recordingSessionVideoPath);
}
if (options.currentVideoPath) {
return fromFileUrl(options.currentVideoPath);
return getLocalFilePathFromResource(options.currentVideoPath);
}
return null;
@@ -66,9 +66,11 @@ type PartialEditorControls = Partial<PersistedEditorControls>;
type PresetAutoCaptionSettings = ProjectEditorState["autoCaptionSettings"];
type PresetCropRegion = ProjectEditorState["cropRegion"];
type PresetWebcamSettings = Omit<ProjectEditorState["webcam"], "sourcePath">;
export interface EditorPresetSnapshot extends PersistedEditorControls {
export interface EditorPresetSnapshot extends Omit<PersistedEditorControls, "webcam"> {
cropRegion: PresetCropRegion;
webcam: PresetWebcamSettings;
autoCaptionSettings: PresetAutoCaptionSettings;
whisperExecutablePath: string | null;
whisperModelPath: string | null;
@@ -209,9 +211,15 @@ function normalizeEditorPresetSnapshot(candidate: unknown): EditorPresetSnapshot
const normalizedCropRegion = normalizeProjectEditor({
cropRegion: raw.cropRegion,
}).cropRegion;
const normalizedControls = normalizeEditorControls(
normalizedPreferences,
normalizedPreferences,
);
const { sourcePath: _sourcePath, ...webcam } = normalizedControls.webcam;
return {
...normalizeEditorControls(normalizedPreferences, normalizedPreferences),
...normalizedControls,
webcam,
cropRegion: normalizedCropRegion,
autoCaptionSettings: normalizePresetAutoCaptionSettings(raw.autoCaptionSettings),
whisperExecutablePath:
@@ -2,7 +2,7 @@ import type { ExportProgress } from "@/lib/exporter";
import { toFileUrl } from "../projectPersistence";
import type { useAppearanceState } from "../state/useAppearanceState";
import type { useTimelineState } from "../state/useTimelineState";
import type { CaptionCue, CursorTelemetryPoint, SpeedRegion, ZoomRegion } from "../types";
import type { CursorTelemetryPoint, SpeedRegion, ZoomRegion } from "../types";
type AppearanceState = ReturnType<typeof useAppearanceState>;
type TimelineState = ReturnType<typeof useTimelineState>;
@@ -61,7 +61,7 @@ export function buildExportRenderOptions({
appearance.resolvedWebcamVideoUrl ??
(appearance.webcam.sourcePath ? toFileUrl(appearance.webcam.sourcePath) : null),
annotationRegions: timeline.annotationRegions,
autoCaptions: timeline.autoCaptions as CaptionCue[],
autoCaptions: timeline.autoCaptions,
autoCaptionSettings: timeline.autoCaptionSettings,
zoomRegions: effectiveZoomRegions,
cursorTelemetry: effectiveCursorTelemetry,
@@ -56,10 +56,14 @@ export function useExportDialogActions({
toast.error("Video not ready");
return;
}
if (video.videoWidth <= 0 || video.videoHeight <= 0) {
toast.error("Video metadata is still loading");
return;
}
const resolvedSettings = resolveExportStartSettings({
sourceWidth: video.videoWidth || 1920,
sourceHeight: video.videoHeight || 1080,
sourceWidth: video.videoWidth,
sourceHeight: video.videoHeight,
exportFormat: settings.exportFormat,
includeCaptionSidecar: hasCaptionsForSidecar && settings.includeCaptionSidecar,
exportEncodingMode: settings.exportEncodingMode,
@@ -47,20 +47,20 @@ export function useExportDimensions({
});
const requestRef = useRef(0);
const previousProbeRef = useRef<Mp4SupportProbeSnapshot | null>(null);
const gifOutputDimensions = useMemo(
() =>
calculateOutputDimensions(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
gifSizePreset,
GIF_SIZE_PRESETS,
),
[gifSizePreset, videoPlaybackRef],
);
const sourceDimensions = useMemo(() => {
const video = isPreviewReady ? videoPlaybackRef.current?.video : null;
return { width: video?.videoWidth || 1920, height: video?.videoHeight || 1080 };
}, [isPreviewReady, videoPlaybackRef]);
const gifOutputDimensions = useMemo(
() =>
calculateOutputDimensions(
sourceDimensions.width,
sourceDimensions.height,
gifSizePreset,
GIF_SIZE_PRESETS,
),
[gifSizePreset, sourceDimensions],
);
const desiredSourceDimensions = useMemo(
() =>
calculateMp4SourceDimensions(
@@ -461,7 +461,7 @@ export function useExportRunner(input: ExportRunnerInput) {
}
if (wasPlaying) {
videoPlaybackRef.current?.play();
await videoPlaybackRef.current?.play().catch(() => undefined);
} else {
video.currentTime = restoreTime;
}
@@ -79,32 +79,21 @@ export function useClipRegionCommands({
const handleClipSplit = useCallback(
(splitMs: number) => {
setClipRegions((current) => {
const target = current.find(
(clip) => splitMs > clip.startMs && splitMs < clip.endMs,
);
if (!target) return current;
const leftId = `clip-${nextClipIdRef.current++}`;
const rightId = `clip-${nextClipIdRef.current++}`;
const left: ClipRegion = {
id: leftId,
startMs: target.startMs,
endMs: Math.round(splitMs),
speed: target.speed,
muted: target.muted,
};
const right: ClipRegion = {
id: rightId,
startMs: Math.round(splitMs),
endMs: target.endMs,
speed: target.speed,
muted: target.muted,
};
if (selectedClipId === target.id) setSelectedClipId(leftId);
return current.flatMap((clip) => (clip.id === target.id ? [left, right] : [clip]));
});
const target = clipRegions.find(
(clip) => splitMs > clip.startMs && splitMs < clip.endMs,
);
if (!target) return;
const leftId = `clip-${nextClipIdRef.current++}`;
const rightId = `clip-${nextClipIdRef.current++}`;
const splitAt = Math.round(splitMs);
const left: ClipRegion = { ...target, id: leftId, endMs: splitAt };
const right: ClipRegion = { ...target, id: rightId, startMs: splitAt };
setClipRegions((current) =>
current.flatMap((clip) => (clip.id === target.id ? [left, right] : [clip])),
);
if (selectedClipId === target.id) setSelectedClipId(leftId);
},
[nextClipIdRef, selectedClipId, setClipRegions, setSelectedClipId],
[clipRegions, nextClipIdRef, selectedClipId, setClipRegions, setSelectedClipId],
);
const handleClipSpanChange = useCallback(
@@ -32,6 +32,20 @@ export function useCursorTelemetry({
useEffect(() => {
let mounted = true;
let retryAttempts = 0;
const scheduleRetry = () => {
if (
pendingFreshRecordingAutoZoomPathRef.current !== videoPath ||
autoSuggestedVideoPathRef.current === videoPath ||
retryAttempts >= 12
) {
return;
}
retryAttempts += 1;
pendingRetryTimeoutRef.current = window.setTimeout(() => {
pendingRetryTimeoutRef.current = null;
if (mounted) void load();
}, 350);
};
async function load() {
if (!videoPath || !videoSourcePath) {
if (mounted) {
@@ -45,33 +59,13 @@ export function useCursorTelemetry({
if (!mounted) return;
setCursorTelemetry(result.success ? result.samples : []);
setCursorTelemetrySourcePath(videoSourcePath);
if (
pendingFreshRecordingAutoZoomPathRef.current === videoPath &&
autoSuggestedVideoPathRef.current !== videoPath &&
retryAttempts < 12
) {
retryAttempts += 1;
pendingRetryTimeoutRef.current = window.setTimeout(() => {
pendingRetryTimeoutRef.current = null;
if (mounted) void load();
}, 350);
}
if (!result.success || result.samples.length === 0) scheduleRetry();
} catch (error) {
console.warn("Unable to load cursor telemetry:", error);
if (!mounted) return;
setCursorTelemetry([]);
setCursorTelemetrySourcePath(videoSourcePath);
if (
pendingFreshRecordingAutoZoomPathRef.current === videoPath &&
autoSuggestedVideoPathRef.current !== videoPath &&
retryAttempts < 12
) {
retryAttempts += 1;
pendingRetryTimeoutRef.current = window.setTimeout(() => {
pendingRetryTimeoutRef.current = null;
if (mounted) void load();
}, 350);
}
scheduleRetry();
}
}
@@ -48,7 +48,6 @@ export function useEditorGlobalInteractions({
}
return;
}
if (event.key === "Tab" && !editable) event.preventDefault();
if (!matchesShortcut(event, shortcuts.playPause, isMac) || editable) return;
event.preventDefault();
const playback = videoPlaybackRef.current;
@@ -52,8 +52,16 @@ export function useEditorHistory({
} = timeline;
const historyRef = useRef(createEditorHistoryStack());
const applyingRef = useRef(false);
const [version, setVersion] = useState(0);
const syncButtons = useCallback(() => setVersion((value) => value + 1), []);
const [historyFlags, setHistoryFlags] = useState({ canUndo: false, canRedo: false });
const syncButtons = useCallback(() => {
const next = {
canUndo: historyRef.current.past.length > 0,
canRedo: historyRef.current.future.length > 0,
};
setHistoryFlags((current) =>
current.canUndo === next.canUndo && current.canRedo === next.canRedo ? current : next,
);
}, []);
const buildSnapshot = useCallback(
(): EditorHistorySnapshot => ({
zoomRegions,
@@ -160,10 +168,8 @@ export function useEditorHistory({
if (result !== "unchanged") syncButtons();
}, [buildSnapshot, syncButtons]);
void version;
return {
canUndo: historyRef.current.past.length > 0,
canRedo: historyRef.current.future.length > 0,
...historyFlags,
handleUndo,
handleRedo,
resetHistory,
@@ -57,7 +57,6 @@ export function useTimelineProjection({
);
}
timeline.setClipRegions(nextRegions);
if (speedRegions.length > 0) timeline.setSpeedRegions([]);
}
initializedRef.current = true;
return;
@@ -72,15 +71,7 @@ export function useTimelineProjection({
if (!extended) return;
autoFullTrackEndRef.current = totalMs;
timeline.setClipRegions(extended);
}, [
duration,
clipRegions,
trimRegions,
speedRegions,
nextClipIdRef,
timeline.setClipRegions,
timeline.setSpeedRegions,
]);
}, [duration, clipRegions, trimRegions, nextClipIdRef, timeline.setClipRegions]);
useEffect(() => {
const totalMs = Math.round(duration * 1000);
@@ -75,7 +75,7 @@ export function useZoomRegionCommands({
endMs: Math.round(span.end),
depth,
focus: clampFocusToDepth({ cx: 0.5, cy: 0.5 }, depth),
mode: "auto",
mode: "manual",
};
markFreshRecordingSuggestion();
setZoomRegions((current) => [...current, newRegion]);
@@ -1,6 +1,6 @@
import { X } from "@phosphor-icons/react";
import type { Dispatch, SetStateAction } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import type { useI18n } from "@/contexts/I18nContext";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
import { CropControl } from "../CropControl";
@@ -27,31 +27,18 @@ export function CropEditorDialog({
onCancel,
onDone,
}: Props) {
if (!open) return null;
return (
<>
<div
className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200"
onClick={onCancel}
/>
<div className="fixed left-1/2 top-1/2 z-[60] max-h-[90vh] w-[90vw] max-w-5xl -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-2xl border border-foreground/10 bg-editor-dialog p-8 shadow-2xl animate-in zoom-in-95 duration-200">
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onCancel()}>
<DialogContent className="max-h-[90vh] w-[90vw] max-w-5xl overflow-auto rounded-2xl border-foreground/10 bg-editor-dialog p-8 shadow-2xl">
<div className="mb-6 flex items-center justify-between">
<div>
<span className="text-xl font-bold text-foreground">
<DialogTitle className="text-xl font-bold text-foreground">
{t("settings.crop.title")}
</span>
</DialogTitle>
<p className="mt-2 text-sm text-muted-foreground">
{t("settings.crop.instruction")}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={onCancel}
className="text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
>
<X className="h-5 w-5" />
</Button>
</div>
<CropControl
videoElement={videoElement}
@@ -68,7 +55,7 @@ export function CropEditorDialog({
{t("common.actions.done")}
</Button>
</div>
</div>
</>
</DialogContent>
</Dialog>
);
}
@@ -136,9 +136,15 @@ export function EditorDialogs({
>
<DialogContent className="max-w-sm border-foreground/10 bg-editor-dialog text-foreground">
<DialogHeader>
<DialogTitle>Unsaved changes</DialogTitle>
<DialogTitle>
{t("editor.project.unsavedChangesTitle", "Unsaved changes")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{`Save your current project before you ${unsavedChangesDialogActionLabel}?`}
{t(
"editor.project.unsavedChangesDescription",
"Save your current project before you {{action}}?",
{ action: unsavedChangesDialogActionLabel },
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -154,10 +160,10 @@ export function EditorDialogs({
variant="ghost"
onClick={() => resolveUnsavedChangesDialog("discard")}
>
Discard changes
{t("editor.project.discardChanges", "Discard changes")}
</Button>
<Button type="button" onClick={() => resolveUnsavedChangesDialog("save")}>
Save project
{t("editor.project.saveProject", "Save project")}
</Button>
</DialogFooter>
</DialogContent>
@@ -240,7 +240,7 @@ export function EditorExportMenu(props: Props) {
</p>
) : null}
<p className="mt-3 truncate text-xs text-muted-foreground/70">
{exportedFilePath.split("/").pop()}
{exportedFilePath.split(/[\\/]/).pop()}
</p>
<div className="mt-4 flex gap-2">
<Button
@@ -375,6 +375,7 @@ export function EditorPreviewPanel(props: Props) {
</span>
<input
type="range"
aria-label={t("editor.playback.volume", "Preview volume")}
min="0"
max="1"
step="0.01"
@@ -100,8 +100,10 @@ export function EditorSidebar({ t, activeSection, setActiveSection, settingsPane
<div className="mt-auto flex flex-col items-center gap-0.5 pt-3">
<motion.button
type="button"
onClick={() => toast.info("Account coming soon")}
title="Account"
onClick={() =>
toast.info(t("editor.account.comingSoon", "Account coming soon"))
}
title={t("editor.account.title", "Account")}
className="group relative flex h-9 w-9 items-center justify-center rounded-lg text-foreground/55 outline-none transition hover:text-foreground focus:outline-none focus-visible:outline-none"
whileHover={{ opacity: 1 }}
initial={{ opacity: 0.55 }}
@@ -92,7 +92,7 @@ export function EditorVideoPreview({
zoomInEasing={appearance.zoomInEasing}
zoomOutEasing={appearance.zoomOutEasing}
connectedZoomEasing={appearance.connectedZoomEasing}
borderRadius={0}
borderRadius={appearance.borderRadius}
padding={appearance.padding}
cropRegion={appearance.cropRegion}
webcam={appearance.webcam}
@@ -78,7 +78,7 @@ export function useVideoEditorPresets({
borderRadius: appearance.borderRadius,
padding: { ...appearance.padding },
cropRegion: { ...appearance.cropRegion },
webcam: { ...appearance.webcam },
webcam: (({ sourcePath: _sourcePath, ...settings }) => settings)(appearance.webcam),
aspectRatio,
exportEncodingMode: exportSettings.exportEncodingMode,
exportBackendPreference: exportSettings.exportBackendPreference,
@@ -145,7 +145,10 @@ export function useVideoEditorPresets({
appearance.setBorderRadius(snapshot.borderRadius);
appearance.setPadding({ ...snapshot.padding });
appearance.setCropRegion({ ...snapshot.cropRegion });
appearance.setWebcam({ ...snapshot.webcam });
appearance.setWebcam((current) => ({
...snapshot.webcam,
sourcePath: current.sourcePath,
}));
setAspectRatio(snapshot.aspectRatio);
exportSettings.setExportEncodingMode(snapshot.exportEncodingMode);
exportSettings.setExportBackendPreference(snapshot.exportBackendPreference);
@@ -144,13 +144,10 @@ export function useEditorProjectController(input: Input) {
project: input.project,
appearance: input.appearance,
timeline: input.timeline,
exportSettings: input.exportSettings,
initialPreferences: input.initialPreferences,
smokeConfig: input.smokeConfig,
devConfig: input.devConfig,
videoSourcePath: input.videoSourcePath,
pendingFreshRecordingAutoZoomPathRef: input.pendingFreshRecordingAutoZoomPathRef,
setAspectRatio: input.setAspectRatio,
applyLoadedProject: lifecycle.applyLoadedProject,
resetSourceScopedEditorState: lifecycle.resetSourceScopedEditorState,
applySessionPresentation: input.applySessionPresentation,
@@ -1,8 +1,5 @@
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: editor state setters are stable and initial source loading intentionally runs once per launch configuration. */
import { type MutableRefObject, useEffect, useRef } from "react";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
import type { EditorPreferences } from "../editorPreferences";
import type { useExportSettings } from "../export/useExportSettings";
import { fromFileUrl, resolveVideoUrl } from "../projectPersistence";
import type { getDevOpenRecordingConfig, getSmokeExportConfig } from "../smokeExportConfig";
import type { useAppearanceState } from "../state/useAppearanceState";
@@ -19,13 +16,10 @@ type Input = {
project: ReturnType<typeof useProjectState>;
appearance: ReturnType<typeof useAppearanceState>;
timeline: ReturnType<typeof useTimelineState>;
exportSettings: ReturnType<typeof useExportSettings>;
initialPreferences: EditorPreferences;
smokeConfig: ReturnType<typeof getSmokeExportConfig>;
devConfig: ReturnType<typeof getDevOpenRecordingConfig>;
videoSourcePath: string | null;
pendingFreshRecordingAutoZoomPathRef: MutableRefObject<string | null>;
setAspectRatio: (value: AspectRatio) => void;
applyLoadedProject: (candidate: unknown, path?: string | null) => Promise<boolean>;
resetSourceScopedEditorState: () => void;
applySessionPresentation: (session: SessionPresentation | null | undefined) => void;
@@ -35,13 +29,10 @@ export function useInitialEditorSource({
project,
appearance,
timeline,
exportSettings,
initialPreferences,
smokeConfig,
devConfig,
videoSourcePath,
pendingFreshRecordingAutoZoomPathRef,
setAspectRatio,
applyLoadedProject,
resetSourceScopedEditorState,
applySessionPresentation,
@@ -160,20 +151,6 @@ export function useInitialEditorSource({
currentProject.project &&
(await applyLoadedProject(currentProject.project, currentProject.path ?? null))
) {
appearance.setPadding(initialPreferences.padding);
appearance.setBorderRadius(initialPreferences.borderRadius);
setAspectRatio(initialPreferences.aspectRatio);
exportSettings.setExportFormat(initialPreferences.exportFormat);
exportSettings.setMp4FrameRate(initialPreferences.mp4FrameRate ?? 30);
exportSettings.setExportQuality(initialPreferences.exportQuality);
exportSettings.setExportEncodingMode(initialPreferences.exportEncodingMode);
exportSettings.setExportBackendPreference(
initialPreferences.exportBackendPreference,
);
exportSettings.setExportPipelineModel(initialPreferences.exportPipelineModel);
exportSettings.setGifFrameRate(initialPreferences.gifFrameRate);
exportSettings.setGifLoop(initialPreferences.gifLoop);
exportSettings.setGifSizePreset(initialPreferences.gifSizePreset);
return;
}
@@ -229,7 +206,6 @@ export function useInitialEditorSource({
applyLoadedProject,
applySessionPresentation,
devConfig,
initialPreferences,
resetSourceScopedEditorState,
smokeConfig,
]);
@@ -1,5 +1,5 @@
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: grouped editor domain objects contain the thumbnail renderer dependencies. */
import { type RefObject, useCallback } from "react";
import { type RefObject, useCallback, useRef } from "react";
import { FrameRenderer } from "@/lib/exporter";
import { toFileUrl } from "../projectPersistence";
import type { useAppearanceState } from "../state/useAppearanceState";
@@ -25,6 +25,8 @@ export function useProjectLibraryController({
currentTime,
effectiveShowCursor,
}: Input) {
const currentTimeRef = useRef(currentTime);
currentTimeRef.current = currentTime;
const { setProjectLibraryEntries } = project;
const {
backgroundBlur,
@@ -129,7 +131,7 @@ export function useProjectLibraryController({
const previewWidth = previewHandle?.containerRef.current?.clientWidth || 1920;
const previewHeight = previewHandle?.containerRef.current?.clientHeight || 1080;
const frameTimestampUs = Math.max(0, Math.round(currentTime * 1_000_000));
const frameTimestampUs = Math.max(0, Math.round(currentTimeRef.current * 1_000_000));
if (previewVideo && previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0) {
let videoFrame: VideoFrame | null = null;
@@ -279,7 +281,6 @@ export function useProjectLibraryController({
connectedZoomEasing,
connectedZoomGapMs,
cropRegion,
currentTime,
cursorClickBounce,
cursorClickBounceDuration,
cursorClickEffect,
@@ -108,7 +108,7 @@ export function useProjectSaveActions({
}
if (forceSaveAs || !targetPath) {
if (options?.silent) return false;
return openProjectSaveDialog(projectDisplayName || fileNameBase);
return await openProjectSaveDialog(projectDisplayName || fileNameBase);
}
const thumbnail = captureThumbnail
+8 -2
View File
@@ -4,7 +4,8 @@
"pause": "Pause",
"skipBack": "Zurück springen",
"skipForward": "Vorwärts springen",
"muteUnmute": "Stummschalten/Stummschaltung aufheben"
"muteUnmute": "Stummschalten/Stummschaltung aufheben",
"volume": "Vorschaulautstärke"
},
"annotations": {
"settings": "Einstellungen für Anmerkungen",
@@ -110,8 +111,13 @@
"showInFolder": "In Ordner anzeigen"
},
"project": {
"untitled": "Unbenannt"
"untitled": "Unbenannt",
"unsavedChangesTitle": "Ungespeicherte Änderungen",
"unsavedChangesDescription": "Möchtest du dein aktuelles Projekt speichern, bevor du {{action}}?",
"discardChanges": "Änderungen verwerfen",
"saveProject": "Projekt speichern"
},
"account": { "title": "Konto", "comingSoon": "Konto demnächst verfügbar" },
"nativeCaptureUnavailable": {
"title": "Es ist nichts kaputt, aber wir können kein animiertes Cursor-Overlay rendern.",
"description": "Ihr Gerät unterstützt keine native Erfassung. Dies kann verschiedene Gründe haben, die wir noch nicht ermittelt haben. Recordly funktioniert weiterhin, aber eine Cursor-Glättung ist nicht möglich.",
+8 -2
View File
@@ -4,7 +4,8 @@
"pause": "Pause",
"skipBack": "Skip Back",
"skipForward": "Skip Forward",
"muteUnmute": "Mute/Unmute"
"muteUnmute": "Mute/Unmute",
"volume": "Preview volume"
},
"annotations": {
"settings": "Annotation Settings",
@@ -111,8 +112,13 @@
"showInFolder": "Show In Folder"
},
"project": {
"untitled": "Untitled"
"untitled": "Untitled",
"unsavedChangesTitle": "Unsaved changes",
"unsavedChangesDescription": "Save your current project before you {{action}}?",
"discardChanges": "Discard changes",
"saveProject": "Save project"
},
"account": { "title": "Account", "comingSoon": "Account coming soon" },
"nativeCaptureUnavailable": {
"title": "Nothing’s broken, but we won’t be able to render an animated cursor overlay.",
"description": "Your device does not support native capture. This could be for a variety of reasons we haven’t figured out yet. This doesn’t break Recordly, but it does make cursor smoothing impossible.",
+8 -2
View File
@@ -4,7 +4,8 @@
"pause": "Pausar",
"skipBack": "Retroceder",
"skipForward": "Avanzar",
"muteUnmute": "Silenciar/activar sonido"
"muteUnmute": "Silenciar/activar sonido",
"volume": "Volumen de vista previa"
},
"annotations": {
"settings": "Configuración de anotaciones",
@@ -111,8 +112,13 @@
"showInFolder": "Mostrar en carpeta"
},
"project": {
"untitled": "Sin título"
"untitled": "Sin título",
"unsavedChangesTitle": "Cambios sin guardar",
"unsavedChangesDescription": "¿Quieres guardar el proyecto actual antes de {{action}}?",
"discardChanges": "Descartar cambios",
"saveProject": "Guardar proyecto"
},
"account": { "title": "Cuenta", "comingSoon": "Cuenta próximamente" },
"nativeCaptureUnavailable": {
"title": "Nada está roto, pero no podremos renderizar una superposición de cursor animada.",
"description": "Tu dispositivo no es compatible con la captura nativa. Esto puede deberse a varias razones que todavía no hemos identificado. Recordly seguirá funcionando, pero hará imposible el suavizado del cursor.",
+8 -2
View File
@@ -4,7 +4,8 @@
"pause": "Pause",
"skipBack": "Reculer",
"skipForward": "Avancer",
"muteUnmute": "Activer/Désactiver le son"
"muteUnmute": "Activer/Désactiver le son",
"volume": "Volume de l’aperçu"
},
"annotations": {
"settings": "Paramètres des annotations",
@@ -111,8 +112,13 @@
"showInFolder": "Afficher dans le dossier"
},
"project": {
"untitled": "Sans titre"
"untitled": "Sans titre",
"unsavedChangesTitle": "Modifications non enregistrées",
"unsavedChangesDescription": "Enregistrer le projet actuel avant de {{action}} ?",
"discardChanges": "Ignorer les modifications",
"saveProject": "Enregistrer le projet"
},
"account": { "title": "Compte", "comingSoon": "Compte bientôt disponible" },
"nativeCaptureUnavailable": {
"title": "Rien n'est cassé, mais nous ne pourrons pas afficher une superposition animée du curseur.",
"description": "Votre appareil ne prend pas en charge la capture native. Cela peut arriver pour plusieurs raisons que nous n'avons pas encore identifiées. Recordly continuera de fonctionner, mais le lissage du curseur sera impossible.",
+8 -2
View File
@@ -4,7 +4,8 @@
"pause": "Pausa",
"skipBack": "Indietro",
"skipForward": "Avanti",
"muteUnmute": "Disattiva/Attiva audio"
"muteUnmute": "Disattiva/Attiva audio",
"volume": "Volume anteprima"
},
"annotations": {
"settings": "Impostazioni annotazione",
@@ -111,8 +112,13 @@
"showInFolder": "Mostra nella cartella"
},
"project": {
"untitled": "Senza titolo"
"untitled": "Senza titolo",
"unsavedChangesTitle": "Modifiche non salvate",
"unsavedChangesDescription": "Salvare il progetto corrente prima di {{action}}?",
"discardChanges": "Ignora modifiche",
"saveProject": "Salva progetto"
},
"account": { "title": "Account", "comingSoon": "Account in arrivo" },
"nativeCaptureUnavailable": {
"title": "Niente è rotto, ma non sarà possibile renderizzare un overlay del cursore animato.",
"description": "Il tuo dispositivo non supporta la cattura nativa. Le cause possono essere varie e non ancora identificate. Recordly funziona comunque, ma non è possibile applicare lo smoothing del cursore.",
+8 -2
View File
@@ -4,7 +4,8 @@
"pause": "일시 정지",
"skipBack": "뒤로 건너뛰기",
"skipForward": "앞으로 건너뛰기",
"muteUnmute": "음소거/해제"
"muteUnmute": "음소거/해제",
"volume": "미리보기 볼륨"
},
"annotations": {
"settings": "주석 설정",
@@ -112,8 +113,13 @@
"showInFolder": "폴더에서 보기"
},
"project": {
"untitled": "제목 없음"
"untitled": "제목 없음",
"unsavedChangesTitle": "저장되지 않은 변경 사항",
"unsavedChangesDescription": "{{action}} 전에 현재 프로젝트를 저장하시겠습니까?",
"discardChanges": "변경 사항 버리기",
"saveProject": "프로젝트 저장"
},
"account": { "title": "계정", "comingSoon": "계정 기능 준비 중" },
"nativeCaptureUnavailable": {
"title": "문제가 생긴 것은 아니지만, 애니메이션 커서 오버레이를 렌더링할 수 없습니다.",
"description": "이 장치는 네이티브 캡처를 지원하지 않습니다. 아직 확인하지 못한 여러 이유가 있을 수 있습니다. Recordly는 계속 작동하지만 커서 스무딩은 사용할 수 없습니다.",
+8 -2
View File
@@ -4,7 +4,8 @@
"pause": "Pauzeren",
"skipBack": "Terug springen",
"skipForward": "Vooruit springen",
"muteUnmute": "Dempen/dempen opheffen"
"muteUnmute": "Dempen/dempen opheffen",
"volume": "Voorbeeldvolume"
},
"annotations": {
"settings": "Annotatie-instellingen",
@@ -112,8 +113,13 @@
"showInFolder": "Tonen in map"
},
"project": {
"untitled": "Naamloos"
"untitled": "Naamloos",
"unsavedChangesTitle": "Niet-opgeslagen wijzigingen",
"unsavedChangesDescription": "Wil je het huidige project opslaan voordat je {{action}}?",
"discardChanges": "Wijzigingen negeren",
"saveProject": "Project opslaan"
},
"account": { "title": "Account", "comingSoon": "Account binnenkort beschikbaar" },
"nativeCaptureUnavailable": {
"title": "Er is niets kapot, maar we kunnen geen geanimeerde cursor-overlay renderen.",
"description": "Je apparaat ondersteunt geen native capture. Dit kan verschillende oorzaken hebben die we nog niet hebben achterhaald. Recordly blijft werken, maar cursor smoothing is dan niet mogelijk.",
+8 -2
View File
@@ -4,7 +4,8 @@
"pause": "Pausar",
"skipBack": "Voltar",
"skipForward": "Avançar",
"muteUnmute": "Silenciar/ativar som"
"muteUnmute": "Silenciar/ativar som",
"volume": "Volume da prévia"
},
"annotations": {
"settings": "Configurações de anotação",
@@ -111,8 +112,13 @@
"showInFolder": "Mostrar na pasta"
},
"project": {
"untitled": "Sem título"
"untitled": "Sem título",
"unsavedChangesTitle": "Alterações não salvas",
"unsavedChangesDescription": "Salvar o projeto atual antes de {{action}}?",
"discardChanges": "Descartar alterações",
"saveProject": "Salvar projeto"
},
"account": { "title": "Conta", "comingSoon": "Conta em breve" },
"nativeCaptureUnavailable": {
"title": "Nada está quebrado, mas não poderemos renderizar uma sobreposição animada do cursor.",
"description": "Seu dispositivo não oferece suporte à captura nativa. Isso pode acontecer por vários motivos que ainda não identificamos. O Recordly continuará funcionando, mas a suavização do cursor ficará indisponível.",
+8 -2
View File
@@ -4,7 +4,8 @@
"pause": "Пауза",
"skipBack": "Перемотать назад",
"skipForward": "Перемотать вперёд",
"muteUnmute": "Вкл./выкл. звук"
"muteUnmute": "Вкл./выкл. звук",
"volume": "Громкость предпросмотра"
},
"annotations": {
"settings": "Настройки аннотации",
@@ -111,8 +112,13 @@
"showInFolder": "Показать в папке"
},
"project": {
"untitled": "Без названия"
"untitled": "Без названия",
"unsavedChangesTitle": "Несохранённые изменения",
"unsavedChangesDescription": "Сохранить текущий проект перед действием «{{action}}»?",
"discardChanges": "Отменить изменения",
"saveProject": "Сохранить проект"
},
"account": { "title": "Учётная запись", "comingSoon": "Учётная запись скоро появится" },
"nativeCaptureUnavailable": {
"title": "Всё в порядке, но мы не можем отобразить анимированное наложение курсора.",
"description": "Устройство не поддерживает нативный захват изображения. Запись продолжится, но без сглаживания курсора.",
+8 -2
View File
@@ -4,7 +4,8 @@
"pause": "暂停",
"skipBack": "后退",
"skipForward": "前进",
"muteUnmute": "静音/取消静音"
"muteUnmute": "静音/取消静音",
"volume": "预览音量"
},
"annotations": {
"settings": "注释设置",
@@ -111,8 +112,13 @@
"showInFolder": "在文件夹中显示"
},
"project": {
"untitled": "未命名"
"untitled": "未命名",
"unsavedChangesTitle": "未保存的更改",
"unsavedChangesDescription": "在{{action}}之前保存当前项目吗?",
"discardChanges": "放弃更改",
"saveProject": "保存项目"
},
"account": { "title": "账户", "comingSoon": "账户功能即将推出" },
"nativeCaptureUnavailable": {
"title": "没有出错,但我们无法渲染动画光标叠加层。",
"description": "你的设备不支持原生捕获。这可能是由我们尚未确定的多种原因造成的。Recordly 仍可继续运行,但无法进行光标平滑处理。",
+8 -2
View File
@@ -4,7 +4,8 @@
"pause": "暫停",
"skipBack": "後退",
"skipForward": "前進",
"muteUnmute": "靜音/取消靜音"
"muteUnmute": "靜音/取消靜音",
"volume": "預覽音量"
},
"annotations": {
"settings": "註釋設定",
@@ -111,8 +112,13 @@
"showInFolder": "在資料夾中顯示"
},
"project": {
"untitled": "未命名"
"untitled": "未命名",
"unsavedChangesTitle": "未儲存的變更",
"unsavedChangesDescription": "要在{{action}}之前儲存目前的專案嗎?",
"discardChanges": "捨棄變更",
"saveProject": "儲存專案"
},
"account": { "title": "帳號", "comingSoon": "帳號功能即將推出" },
"nativeCaptureUnavailable": {
"title": "沒有出錯,但我們無法轉譯動畫游標覆蓋層。",
"description": "你的裝置不支援原生擷取。這可能是由我們尚未釐清的多種原因造成的。Recordly 仍可繼續運作,但無法進行游標平滑處理。",