mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
Merge remote-tracking branch 'origin/main' into codex/clip-flow-cleanup
# Conflicts: # src/components/video-editor/timeline/TimelineEditor.tsx
This commit is contained in:
+1
-1
@@ -49,7 +49,7 @@ Areas where help is especially valuable:
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/webadderall/Recordly/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively.
|
||||
If you encounter a bug or have a feature request, please open an issue in the [Issues](https://github.com/webadderallorg/Recordly/issues) section of this repository. Provide as much detail as possible to help us address the issue effectively.
|
||||
|
||||
## Style Guide
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Recordly
|
||||
|
||||
Language: EN | [简中](README.zh-CN.md)
|
||||
|
||||
<p align="center">
|
||||
@@ -11,12 +9,11 @@ Language: EN | [简中](README.zh-CN.md)
|
||||
<img src="https://img.shields.io/badge/open%20source-AGPL3.0-2563eb?style=for-the-badge" alt="AGPL 3.0 license" />
|
||||
</p>
|
||||
|
||||
### Create polished, pro-grade screen recordings.
|
||||
### Create polished screen recordings without editing.
|
||||
[Recordly](https://www.recordly.dev) is an **open-source screen recorder** and editor for **walkthroughs, demos, product videos**, and more.
|
||||
**Accepting PRs.** [Donate](https://ko-fi.com/webadderall/goal?g=0)
|
||||
|
||||
**Contribution encouraged.** [Donate](https://ko-fi.com/webadderall/goal?g=0)
|
||||
|
||||
https://github.com/user-attachments/assets/1446cd12-c053-4b9c-b49f-d9c93db77fc4
|
||||
https://github.com/user-attachments/assets/9b66c71d-ac97-49ff-a0c9-63ac26edf2e4
|
||||
|
||||
---
|
||||
|
||||
@@ -151,11 +148,11 @@ Browse and install community extensions from the [Recordly Marketplace](https://
|
||||
# Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="https://i.postimg.cc/CKxm8DRs/Screenshot-2026-03-20-at-7-07-22-pm.png" width="700" alt="Recordly editor screenshot">
|
||||
<img src="https://i.postimg.cc/8CrQtGJf/Screenshot-2026-04-30-at-5-11-52-pm.png" width="700" alt="Recordly recording interface screenshot">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://i.postimg.cc/hjwdYRyV/Screenshot-2026-03-20-at-1-53-57-pm.png" width="700" alt="Recordly recording interface screenshot">
|
||||
<img src="https://i.postimg.cc/pLSMfrTM/Screenshot-2026-04-30-at-5-11-45-pm.png" width="700" alt="Recordly editor screenshot">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -170,7 +167,7 @@ Browse and install community extensions from the [Recordly Marketplace](https://
|
||||
|
||||
Prebuilt releases are available at:
|
||||
|
||||
https://github.com/webadderall/Recordly/releases
|
||||
https://github.com/webadderallorg/Recordly/releases
|
||||
|
||||
---
|
||||
|
||||
@@ -203,7 +200,7 @@ sudo apt install build-essential cmake libx11-dev libxtst-dev libxrandr-dev libx
|
||||
### Steps
|
||||
|
||||
```bash
|
||||
git clone https://github.com/webadderall/Recordly.git recordly
|
||||
git clone https://github.com/webadderallorg/Recordly.git recordly
|
||||
cd recordly
|
||||
npm install
|
||||
npm run dev
|
||||
@@ -360,7 +357,7 @@ See `CONTRIBUTING.md` for guidelines.
|
||||
|
||||
Bug reports and feature requests:
|
||||
|
||||
https://github.com/webadderall/Recordly/issues
|
||||
https://github.com/webadderallorg/Recordly/issues
|
||||
|
||||
Pull requests are welcome.
|
||||
|
||||
@@ -374,6 +371,8 @@ Pull requests are welcome.
|
||||
- buildwithfur
|
||||
- Tobias
|
||||
- Anonymous Supporter
|
||||
- Tandava Appadoo
|
||||
- Digitalfastmind
|
||||
- Roberto Marcelino
|
||||
- Rajan RK
|
||||
- Francesco
|
||||
|
||||
+3
-3
@@ -171,7 +171,7 @@ Recordly 拥有一个社区驱动的扩展系统。任何人都可以构建和
|
||||
|
||||
预构建发布版本请见:
|
||||
|
||||
https://github.com/webadderall/Recordly/releases
|
||||
https://github.com/webadderallorg/Recordly/releases
|
||||
|
||||
---
|
||||
|
||||
@@ -204,7 +204,7 @@ sudo apt install build-essential cmake libx11-dev libxtst-dev libxrandr-dev libx
|
||||
### 步骤
|
||||
|
||||
```bash
|
||||
git clone https://github.com/webadderall/Recordly.git recordly
|
||||
git clone https://github.com/webadderallorg/Recordly.git recordly
|
||||
cd recordly
|
||||
npm install
|
||||
npm run dev
|
||||
@@ -361,7 +361,7 @@ Recordly 将平台相关的捕获层与基于渲染器的编辑、导出流程
|
||||
|
||||
问题反馈和功能建议:
|
||||
|
||||
https://github.com/webadderall/Recordly/issues
|
||||
https://github.com/webadderallorg/Recordly/issues
|
||||
|
||||
欢迎提交 Pull Request。
|
||||
|
||||
|
||||
Vendored
+10
@@ -151,6 +151,16 @@ interface Window {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
pauseCursorCapture: (boundaryMs?: number) => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
resumeCursorCapture: (boundaryMs?: number) => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
startFfmpegRecording: (
|
||||
source: ProcessedDesktopSource,
|
||||
) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createRequire } from "node:module";
|
||||
import type { HookMouseEvent, UiohookLike, UiohookModuleNamespace, CursorInteractionType } from "../types";
|
||||
import {
|
||||
isCursorCaptureActive,
|
||||
cursorCaptureStartTimeMs,
|
||||
interactionCaptureCleanup,
|
||||
setInteractionCaptureCleanup,
|
||||
hasLoggedInteractionHookFailure,
|
||||
@@ -13,7 +12,9 @@ import {
|
||||
} from "../state";
|
||||
import {
|
||||
getNormalizedCursorPoint,
|
||||
getCursorCaptureElapsedMs,
|
||||
getHookCursorScreenPoint,
|
||||
isCursorCapturePaused,
|
||||
pushCursorSample,
|
||||
} from "./telemetry";
|
||||
|
||||
@@ -119,7 +120,7 @@ export async function startInteractionCapture() {
|
||||
}
|
||||
|
||||
const onMouseDown = (event: HookMouseEvent) => {
|
||||
if (!isCursorCaptureActive) {
|
||||
if (!isCursorCaptureActive || isCursorCapturePaused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,7 +129,7 @@ export async function startInteractionCapture() {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeMs = Date.now() - cursorCaptureStartTimeMs;
|
||||
const timeMs = getCursorCaptureElapsedMs();
|
||||
const button = getHookMouseButton(event);
|
||||
let interactionType: CursorInteractionType = "click";
|
||||
|
||||
@@ -157,7 +158,7 @@ export async function startInteractionCapture() {
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (!isCursorCaptureActive) {
|
||||
if (!isCursorCaptureActive || isCursorCapturePaused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,12 +167,16 @@ export async function startInteractionCapture() {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeMs = Date.now() - cursorCaptureStartTimeMs;
|
||||
const timeMs = getCursorCaptureElapsedMs();
|
||||
pushCursorSample(point.cx, point.cy, timeMs, "mouseup");
|
||||
};
|
||||
|
||||
const onMouseMove = (event: HookMouseEvent) => {
|
||||
if (process.platform !== "linux" || !isCursorCaptureActive) {
|
||||
if (
|
||||
process.platform !== "linux" ||
|
||||
!isCursorCaptureActive ||
|
||||
isCursorCapturePaused()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => "/tmp"),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../utils", () => ({
|
||||
getTelemetryPathForVideo: vi.fn(() => "/tmp/recording.cursor.json"),
|
||||
getScreen: vi.fn(() => ({
|
||||
getCursorScreenPoint: () => ({ x: 0, y: 0 }),
|
||||
getPrimaryDisplay: () => ({ scaleFactor: 1 }),
|
||||
getDisplayNearestPoint: () => ({ bounds: { x: 0, y: 0, width: 1, height: 1 } }),
|
||||
getAllDisplays: () => [],
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
getCursorCaptureElapsedMs,
|
||||
pauseCursorCapture,
|
||||
resetCursorCaptureClock,
|
||||
resumeCursorCapture,
|
||||
} from "./telemetry";
|
||||
import { setCursorCaptureStartTimeMs } from "../state";
|
||||
|
||||
describe("cursor telemetry pause clock", () => {
|
||||
beforeEach(() => {
|
||||
setCursorCaptureStartTimeMs(1_000);
|
||||
resetCursorCaptureClock();
|
||||
});
|
||||
|
||||
it("subtracts paused time from elapsed cursor timestamps", () => {
|
||||
expect(getCursorCaptureElapsedMs(1_120)).toBe(120);
|
||||
|
||||
pauseCursorCapture(1_200);
|
||||
expect(getCursorCaptureElapsedMs(1_450)).toBe(200);
|
||||
|
||||
resumeCursorCapture(1_700);
|
||||
expect(getCursorCaptureElapsedMs(1_900)).toBe(400);
|
||||
});
|
||||
|
||||
it("ignores duplicate pause or resume transitions", () => {
|
||||
pauseCursorCapture(1_150);
|
||||
pauseCursorCapture(1_250);
|
||||
resumeCursorCapture(1_500);
|
||||
resumeCursorCapture(1_650);
|
||||
|
||||
expect(getCursorCaptureElapsedMs(1_900)).toBe(550);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import type { CursorVisualType, CursorInteractionType, CursorTelemetryPoint } fr
|
||||
import {
|
||||
cursorCaptureInterval,
|
||||
setCursorCaptureInterval,
|
||||
cursorCaptureAccumulatedPausedMs,
|
||||
cursorCapturePauseStartedAtMs,
|
||||
cursorCaptureStartTimeMs,
|
||||
activeCursorSamples,
|
||||
pendingCursorSamples,
|
||||
@@ -18,6 +20,8 @@ import {
|
||||
linuxCursorScreenPoint,
|
||||
selectedSource,
|
||||
selectedWindowBounds,
|
||||
setCursorCaptureAccumulatedPausedMs,
|
||||
setCursorCapturePauseStartedAtMs,
|
||||
} from "../state";
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
@@ -31,6 +35,55 @@ export function stopCursorCapture() {
|
||||
}
|
||||
}
|
||||
|
||||
export function resetCursorCaptureClock() {
|
||||
setCursorCaptureAccumulatedPausedMs(0);
|
||||
setCursorCapturePauseStartedAtMs(null);
|
||||
}
|
||||
|
||||
export function isCursorCapturePaused() {
|
||||
return cursorCapturePauseStartedAtMs !== null;
|
||||
}
|
||||
|
||||
export function pauseCursorCapture(pausedAtMs: number) {
|
||||
if (cursorCapturePauseStartedAtMs !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCursorCapturePauseStartedAtMs(pausedAtMs);
|
||||
}
|
||||
|
||||
export function resumeCursorCapture(resumedAtMs: number) {
|
||||
if (cursorCapturePauseStartedAtMs === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pauseDurationMs = Math.max(0, resumedAtMs - cursorCapturePauseStartedAtMs);
|
||||
setCursorCaptureAccumulatedPausedMs(
|
||||
cursorCaptureAccumulatedPausedMs + pauseDurationMs,
|
||||
);
|
||||
setCursorCapturePauseStartedAtMs(null);
|
||||
}
|
||||
|
||||
export function getCursorCaptureElapsedMs(nowMs = Date.now()) {
|
||||
if (!Number.isFinite(cursorCaptureStartTimeMs) || cursorCaptureStartTimeMs <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const safeNowMs = Math.max(cursorCaptureStartTimeMs, nowMs);
|
||||
const activePauseDurationMs =
|
||||
cursorCapturePauseStartedAtMs === null
|
||||
? 0
|
||||
: Math.max(0, safeNowMs - cursorCapturePauseStartedAtMs);
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
safeNowMs -
|
||||
cursorCaptureStartTimeMs -
|
||||
Math.max(0, cursorCaptureAccumulatedPausedMs) -
|
||||
activePauseDurationMs,
|
||||
);
|
||||
}
|
||||
|
||||
export function getNormalizedCursorPoint() {
|
||||
const fallbackCursor = getScreen().getCursorScreenPoint();
|
||||
const linuxCursorCache = process.platform === "linux" ? linuxCursorScreenPoint : null;
|
||||
@@ -115,9 +168,9 @@ export function pushCursorSample(
|
||||
}
|
||||
}
|
||||
|
||||
export function sampleCursorPoint() {
|
||||
export function sampleCursorPoint(sampledAtMs = Date.now()) {
|
||||
const point = getNormalizedCursorPoint();
|
||||
pushCursorSample(point.cx, point.cy, Date.now() - cursorCaptureStartTimeMs, "move");
|
||||
pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(sampledAtMs), "move");
|
||||
}
|
||||
|
||||
export async function persistPendingCursorTelemetry(videoPath: string) {
|
||||
@@ -163,7 +216,7 @@ export function startCursorSampling() {
|
||||
let nextExpectedMs = Date.now() + CURSOR_SAMPLE_INTERVAL_MS;
|
||||
|
||||
const tick = () => {
|
||||
if (isCursorCaptureActive) {
|
||||
if (isCursorCaptureActive && !isCursorCapturePaused()) {
|
||||
sampleCursorPoint();
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ import { startInteractionCapture, stopInteractionCapture } from "../cursor/inter
|
||||
import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/monitor";
|
||||
import {
|
||||
clamp,
|
||||
pauseCursorCapture,
|
||||
resumeCursorCapture,
|
||||
resetCursorCaptureClock,
|
||||
sampleCursorPoint,
|
||||
snapshotCursorTelemetryForPersistence,
|
||||
startCursorSampling,
|
||||
@@ -1275,6 +1278,7 @@ export function registerRecordingHandlers(
|
||||
setActiveCursorSamples([]);
|
||||
setPendingCursorSamples([]);
|
||||
setCursorCaptureStartTimeMs(Date.now());
|
||||
resetCursorCaptureClock();
|
||||
setLinuxCursorScreenPoint(null);
|
||||
setLastLeftClick(null);
|
||||
sampleCursorPoint();
|
||||
@@ -1288,6 +1292,7 @@ export function registerRecordingHandlers(
|
||||
stopNativeCursorMonitor();
|
||||
showCursor();
|
||||
setLinuxCursorScreenPoint(null);
|
||||
resetCursorCaptureClock();
|
||||
snapshotCursorTelemetryForPersistence();
|
||||
setActiveCursorSamples([]);
|
||||
}
|
||||
@@ -1307,6 +1312,26 @@ export function registerRecordingHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("pause-cursor-capture", (_event, boundaryMs?: number) => {
|
||||
const timestamp =
|
||||
typeof boundaryMs === "number" && Number.isFinite(boundaryMs)
|
||||
? boundaryMs
|
||||
: Date.now();
|
||||
sampleCursorPoint(timestamp);
|
||||
pauseCursorCapture(timestamp);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("resume-cursor-capture", (_event, boundaryMs?: number) => {
|
||||
const timestamp =
|
||||
typeof boundaryMs === "number" && Number.isFinite(boundaryMs)
|
||||
? boundaryMs
|
||||
: Date.now();
|
||||
resumeCursorCapture(timestamp);
|
||||
sampleCursorPoint(timestamp);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
|
||||
const targetVideoPath = normalizeVideoSourcePath(videoPath ?? currentVideoPath);
|
||||
if (!targetVideoPath) {
|
||||
|
||||
@@ -76,6 +76,8 @@ export let currentCursorVisualType: CursorVisualType | undefined = undefined;
|
||||
// ── Cursor telemetry ──────────────────────────────────────────────────────────
|
||||
export let cursorCaptureInterval: NodeJS.Timeout | null = null;
|
||||
export let cursorCaptureStartTimeMs = 0;
|
||||
export let cursorCaptureAccumulatedPausedMs = 0;
|
||||
export let cursorCapturePauseStartedAtMs: number | null = null;
|
||||
export let activeCursorSamples: CursorTelemetryPoint[] = [];
|
||||
export let pendingCursorSamples: CursorTelemetryPoint[] = [];
|
||||
export let isCursorCaptureActive = false;
|
||||
@@ -237,6 +239,12 @@ export function setCursorCaptureInterval(v: NodeJS.Timeout | null) {
|
||||
export function setCursorCaptureStartTimeMs(v: number) {
|
||||
cursorCaptureStartTimeMs = v;
|
||||
}
|
||||
export function setCursorCaptureAccumulatedPausedMs(v: number) {
|
||||
cursorCaptureAccumulatedPausedMs = v;
|
||||
}
|
||||
export function setCursorCapturePauseStartedAtMs(v: number | null) {
|
||||
cursorCapturePauseStartedAtMs = v;
|
||||
}
|
||||
export function setActiveCursorSamples(v: CursorTelemetryPoint[]) {
|
||||
activeCursorSamples = v;
|
||||
}
|
||||
|
||||
@@ -293,6 +293,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
resumeNativeScreenRecording: () => {
|
||||
return ipcRenderer.invoke("resume-native-screen-recording");
|
||||
},
|
||||
pauseCursorCapture: (boundaryMs?: number) => {
|
||||
return ipcRenderer.invoke("pause-cursor-capture", boundaryMs);
|
||||
},
|
||||
resumeCursorCapture: (boundaryMs?: number) => {
|
||||
return ipcRenderer.invoke("resume-cursor-capture", boundaryMs);
|
||||
},
|
||||
startFfmpegRecording: (source: ProcessedDesktopSource) => {
|
||||
return ipcRenderer.invoke("start-ffmpeg-recording", source);
|
||||
},
|
||||
|
||||
+3
-3
@@ -3,13 +3,13 @@
|
||||
"productName": "Recordly",
|
||||
"description": "A free, creator-focused screen recorder with auto-zoom, cursor effects, backgrounds, annotations, and more - built for polished videos out of the box.",
|
||||
"author": "webadderall",
|
||||
"homepage": "https://github.com/webadderall/Recordly",
|
||||
"homepage": "https://github.com/webadderallorg/Recordly",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/webadderall/Recordly.git"
|
||||
"url": "https://github.com/webadderallorg/Recordly.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/webadderall/Recordly/issues"
|
||||
"url": "https://github.com/webadderallorg/Recordly/issues"
|
||||
},
|
||||
"private": true,
|
||||
"version": "1.2.0",
|
||||
|
||||
+2
-2
@@ -5,10 +5,10 @@ cask "recordly" do
|
||||
sha256 arm: "e669ab7c8bdd4596211937183ee2374545da5482702cab0dfe477c1466422b0f",
|
||||
intel: "85f5183219de0b656400625797ff9299893bd8fbda8455b0f745878b2c729526"
|
||||
|
||||
url "https://github.com/webadderall/Recordly/releases/download/v#{version}/Recordly-#{arch}.dmg"
|
||||
url "https://github.com/webadderallorg/Recordly/releases/download/v#{version}/Recordly-#{arch}.dmg"
|
||||
name "Recordly"
|
||||
desc "Creator-focused screen recorder with auto-zoom, cursor effects, and more"
|
||||
homepage "https://github.com/webadderall/Recordly"
|
||||
homepage "https://github.com/webadderallorg/Recordly"
|
||||
|
||||
livecheck do
|
||||
url :url
|
||||
|
||||
@@ -61,11 +61,14 @@ interface DesktopSource {
|
||||
}
|
||||
|
||||
const LOCALE_LABELS: Record<string, string> = {
|
||||
en: "EN",
|
||||
es: "ES",
|
||||
nl: "NL",
|
||||
"zh-CN": "中文",
|
||||
en: "English",
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
nl: "Nederlands",
|
||||
ko: "한국어",
|
||||
"pt-BR": "Português",
|
||||
"zh-CN": "簡體中文",
|
||||
"zh-TW": "繁體中文",
|
||||
};
|
||||
|
||||
const COUNTDOWN_OPTIONS = [0, 3, 5, 10];
|
||||
|
||||
@@ -1886,7 +1886,7 @@ export function SettingsPanel({
|
||||
value={borderRadius}
|
||||
defaultValue={initialEditorPreferences.borderRadius}
|
||||
min={0}
|
||||
max={50}
|
||||
max={200}
|
||||
step={0.5}
|
||||
onChange={(v) => onBorderRadiusChange?.(v)}
|
||||
formatValue={(v) => `${v}px`}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcut
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const RECORDLY_ISSUES_URL = "https://github.com/webadderall/Recordly/issues";
|
||||
export const RECORDLY_ISSUES_URL = "https://github.com/webadderallorg/Recordly/issues";
|
||||
const RECORDLY_DISCORD_URL = "https://discord.gg/sdv2FBVNgE";
|
||||
const RECORDLY_X_URL = "https://x.com/webadderall";
|
||||
const CONTACT_EMAIL = "youngchen3442@gmail.com";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BookmarkSimple,
|
||||
Check,
|
||||
CaretDown as ChevronDown,
|
||||
CaretUp as ChevronUp,
|
||||
@@ -38,6 +39,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useI18n } from "@/contexts/I18nContext";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
@@ -77,6 +80,7 @@ import {
|
||||
getAspectRatioLabel,
|
||||
getAspectRatioValue,
|
||||
} from "@/utils/aspectRatioUtils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ExtensionIcon } from "./ExtensionIcon";
|
||||
|
||||
const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => (
|
||||
@@ -101,9 +105,18 @@ const PhSettings = (props: { className?: string; weight?: "fill" | "regular" })
|
||||
import { extensionHost } from "@/lib/extensions";
|
||||
import { resolveAutoCaptionSourcePath } from "./autoCaptionSource";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { updateCaptionCuesForEditedTarget, type CaptionEditTarget } from "./captionEditing";
|
||||
import { ExportSettingsMenu } from "./ExportSettingsMenu";
|
||||
import ExtensionManager from "./ExtensionManager";
|
||||
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
|
||||
import {
|
||||
loadEditorPreferences,
|
||||
loadEditorPresets,
|
||||
saveEditorPreferences,
|
||||
saveEditorPresets,
|
||||
serializeEditorPresetSnapshot,
|
||||
type EditorPreset,
|
||||
type EditorPresetSnapshot,
|
||||
} from "./editorPreferences";
|
||||
import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog";
|
||||
import {
|
||||
createProjectData,
|
||||
@@ -717,6 +730,10 @@ export default function VideoEditor() {
|
||||
const [exportedFilePath, setExportedFilePath] = useState<string | undefined>(undefined);
|
||||
const [hasPendingExportSave, setHasPendingExportSave] = useState(false);
|
||||
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<EditorProjectData | null>(null);
|
||||
const [editorPresets, setEditorPresets] = useState<EditorPreset[]>(() => loadEditorPresets());
|
||||
const [activeEditorPresetId, setActiveEditorPresetId] = useState<string | null>(null);
|
||||
const [presetPopoverOpen, setPresetPopoverOpen] = useState(false);
|
||||
const [presetNameDraft, setPresetNameDraft] = useState("");
|
||||
const [showCropModal, setShowCropModal] = useState(false);
|
||||
const [previewVersion, setPreviewVersion] = useState(0);
|
||||
const [isPreviewReady, setIsPreviewReady] = useState(false);
|
||||
@@ -794,6 +811,285 @@ export default function VideoEditor() {
|
||||
setHistoryVersion((version) => version + 1);
|
||||
}, []);
|
||||
|
||||
const captureEditorPresetSnapshot = useCallback(
|
||||
(): EditorPresetSnapshot => ({
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
backgroundBlur,
|
||||
zoomMotionBlur,
|
||||
connectZooms,
|
||||
zoomInDurationMs,
|
||||
zoomInOverlapMs,
|
||||
zoomOutDurationMs,
|
||||
connectedZoomGapMs,
|
||||
connectedZoomDurationMs,
|
||||
zoomInEasing,
|
||||
zoomOutEasing,
|
||||
connectedZoomEasing,
|
||||
showCursor,
|
||||
loopCursor,
|
||||
cursorStyle,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
borderRadius,
|
||||
padding: { ...padding },
|
||||
frame,
|
||||
webcam: { ...webcam },
|
||||
aspectRatio,
|
||||
exportEncodingMode,
|
||||
exportBackendPreference,
|
||||
exportPipelineModel,
|
||||
exportQuality,
|
||||
mp4FrameRate,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
gifLoop,
|
||||
gifSizePreset,
|
||||
autoCaptionSettings: { ...autoCaptionSettings },
|
||||
whisperExecutablePath,
|
||||
whisperModelPath,
|
||||
}),
|
||||
[
|
||||
wallpaper,
|
||||
shadowIntensity,
|
||||
backgroundBlur,
|
||||
zoomMotionBlur,
|
||||
connectZooms,
|
||||
zoomInDurationMs,
|
||||
zoomInOverlapMs,
|
||||
zoomOutDurationMs,
|
||||
connectedZoomGapMs,
|
||||
connectedZoomDurationMs,
|
||||
zoomInEasing,
|
||||
zoomOutEasing,
|
||||
connectedZoomEasing,
|
||||
showCursor,
|
||||
loopCursor,
|
||||
cursorStyle,
|
||||
cursorSize,
|
||||
cursorSmoothing,
|
||||
cursorMotionBlur,
|
||||
cursorClickBounce,
|
||||
cursorClickBounceDuration,
|
||||
cursorSway,
|
||||
borderRadius,
|
||||
padding,
|
||||
frame,
|
||||
webcam,
|
||||
aspectRatio,
|
||||
exportEncodingMode,
|
||||
exportBackendPreference,
|
||||
exportPipelineModel,
|
||||
exportQuality,
|
||||
mp4FrameRate,
|
||||
exportFormat,
|
||||
gifFrameRate,
|
||||
gifLoop,
|
||||
gifSizePreset,
|
||||
autoCaptionSettings,
|
||||
whisperExecutablePath,
|
||||
whisperModelPath,
|
||||
],
|
||||
);
|
||||
|
||||
const currentPresetSnapshot = useMemo(
|
||||
() => captureEditorPresetSnapshot(),
|
||||
[captureEditorPresetSnapshot],
|
||||
);
|
||||
const currentPresetSignature = useMemo(
|
||||
() => serializeEditorPresetSnapshot(currentPresetSnapshot),
|
||||
[currentPresetSnapshot],
|
||||
);
|
||||
const currentEditorPreset = useMemo(
|
||||
() => editorPresets.find((preset) => preset.id === activeEditorPresetId) ?? null,
|
||||
[activeEditorPresetId, editorPresets],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const activePreset = currentEditorPreset;
|
||||
if (
|
||||
activePreset &&
|
||||
serializeEditorPresetSnapshot(activePreset.snapshot) === currentPresetSignature
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingPreset =
|
||||
editorPresets.find(
|
||||
(preset) =>
|
||||
serializeEditorPresetSnapshot(preset.snapshot) === currentPresetSignature,
|
||||
) ?? null;
|
||||
const nextActivePresetId = matchingPreset?.id ?? null;
|
||||
if (nextActivePresetId !== activeEditorPresetId) {
|
||||
setActiveEditorPresetId(nextActivePresetId);
|
||||
}
|
||||
}, [activeEditorPresetId, currentEditorPreset, currentPresetSignature, editorPresets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!presetPopoverOpen) {
|
||||
setPresetNameDraft("");
|
||||
}
|
||||
}, [presetPopoverOpen]);
|
||||
|
||||
const applyEditorPresetSnapshot = useCallback((snapshot: EditorPresetSnapshot) => {
|
||||
setWallpaper(snapshot.wallpaper);
|
||||
setShadowIntensity(snapshot.shadowIntensity);
|
||||
setBackgroundBlur(snapshot.backgroundBlur);
|
||||
setZoomMotionBlur(snapshot.zoomMotionBlur);
|
||||
setConnectZooms(snapshot.connectZooms);
|
||||
setZoomInDurationMs(snapshot.zoomInDurationMs);
|
||||
setZoomInOverlapMs(snapshot.zoomInOverlapMs);
|
||||
setZoomOutDurationMs(snapshot.zoomOutDurationMs);
|
||||
setConnectedZoomGapMs(snapshot.connectedZoomGapMs);
|
||||
setConnectedZoomDurationMs(snapshot.connectedZoomDurationMs);
|
||||
setZoomInEasing(snapshot.zoomInEasing);
|
||||
setZoomOutEasing(snapshot.zoomOutEasing);
|
||||
setConnectedZoomEasing(snapshot.connectedZoomEasing);
|
||||
setShowCursor(snapshot.showCursor);
|
||||
setLoopCursor(snapshot.loopCursor);
|
||||
setCursorStyle(snapshot.cursorStyle);
|
||||
setCursorSize(snapshot.cursorSize);
|
||||
setCursorSmoothing(snapshot.cursorSmoothing);
|
||||
setCursorMotionBlur(snapshot.cursorMotionBlur);
|
||||
setCursorClickBounce(snapshot.cursorClickBounce);
|
||||
setCursorClickBounceDuration(snapshot.cursorClickBounceDuration);
|
||||
setCursorSway(snapshot.cursorSway);
|
||||
setBorderRadius(snapshot.borderRadius);
|
||||
setPadding({ ...snapshot.padding });
|
||||
setFrame(snapshot.frame);
|
||||
setWebcam({ ...snapshot.webcam });
|
||||
setAspectRatio(snapshot.aspectRatio);
|
||||
setExportEncodingMode(snapshot.exportEncodingMode);
|
||||
setExportBackendPreference(snapshot.exportBackendPreference);
|
||||
setExportPipelineModel(snapshot.exportPipelineModel);
|
||||
setExportQuality(snapshot.exportQuality);
|
||||
setMp4FrameRate(snapshot.mp4FrameRate);
|
||||
setExportFormat(snapshot.exportFormat);
|
||||
setGifFrameRate(snapshot.gifFrameRate);
|
||||
setGifLoop(snapshot.gifLoop);
|
||||
setGifSizePreset(snapshot.gifSizePreset);
|
||||
setAutoCaptionSettings({ ...snapshot.autoCaptionSettings });
|
||||
setWhisperExecutablePath(snapshot.whisperExecutablePath);
|
||||
setWhisperModelPath(snapshot.whisperModelPath);
|
||||
}, []);
|
||||
|
||||
const handleApplyEditorPreset = useCallback(
|
||||
(presetId: string) => {
|
||||
const preset = editorPresets.find((item) => item.id === presetId);
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveEditorPresetId(preset.id);
|
||||
applyEditorPresetSnapshot(preset.snapshot);
|
||||
toast.success(
|
||||
t("editor.presets.toasts.applied", "Applied preset \"{{name}}\"", {
|
||||
name: preset.name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[applyEditorPresetSnapshot, editorPresets, t],
|
||||
);
|
||||
|
||||
const handleSaveEditorPreset = useCallback(
|
||||
(name: string) => {
|
||||
const normalizedName = name.trim().replace(/\s+/g, " ");
|
||||
if (normalizedName.length === 0) {
|
||||
toast.error(t("editor.presets.errors.nameRequired", "Enter a preset name."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasDuplicateName = editorPresets.some(
|
||||
(preset) => preset.name.toLocaleLowerCase() === normalizedName.toLocaleLowerCase(),
|
||||
);
|
||||
if (hasDuplicateName) {
|
||||
toast.error(
|
||||
t(
|
||||
"editor.presets.errors.duplicateName",
|
||||
"A preset with that name already exists.",
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const snapshot = captureEditorPresetSnapshot();
|
||||
const timestamp = new Date().toISOString();
|
||||
const nextPreset: EditorPreset = {
|
||||
id: crypto.randomUUID(),
|
||||
name: normalizedName,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
snapshot,
|
||||
};
|
||||
const nextPresets = [
|
||||
nextPreset,
|
||||
...editorPresets,
|
||||
];
|
||||
|
||||
if (!saveEditorPresets(nextPresets)) {
|
||||
toast.error(
|
||||
t(
|
||||
"editor.presets.errors.saveFailed",
|
||||
"Could not save that preset. Check your browser storage settings and try again.",
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
setEditorPresets(nextPresets);
|
||||
setActiveEditorPresetId(nextPreset.id);
|
||||
toast.success(
|
||||
t("editor.presets.toasts.saved", "Saved preset \"{{name}}\"", {
|
||||
name: normalizedName,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
[captureEditorPresetSnapshot, editorPresets, t],
|
||||
);
|
||||
|
||||
const handleDeleteEditorPreset = useCallback(
|
||||
(presetId: string) => {
|
||||
const preset = editorPresets.find((item) => item.id === presetId);
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPresets = editorPresets.filter((item) => item.id !== presetId);
|
||||
if (!saveEditorPresets(nextPresets)) {
|
||||
toast.error(
|
||||
t(
|
||||
"editor.presets.errors.deleteFailed",
|
||||
"Could not delete that preset. Check your browser storage settings and try again.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setEditorPresets(nextPresets);
|
||||
if (preset.id === activeEditorPresetId) {
|
||||
setActiveEditorPresetId(null);
|
||||
}
|
||||
toast.success(
|
||||
t("editor.presets.toasts.deleted", "Deleted preset \"{{name}}\"", {
|
||||
name: preset.name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[activeEditorPresetId, editorPresets, t],
|
||||
);
|
||||
|
||||
const handleSavePresetSubmit = useCallback(() => {
|
||||
const didSave = handleSaveEditorPreset(presetNameDraft);
|
||||
if (didSave) {
|
||||
setPresetNameDraft("");
|
||||
}
|
||||
}, [handleSaveEditorPreset, presetNameDraft]);
|
||||
|
||||
const clearPendingExportSave = useCallback(() => {
|
||||
const pending = pendingExportSaveRef.current;
|
||||
pendingExportSaveRef.current = null;
|
||||
@@ -2337,6 +2633,14 @@ export default function VideoEditor() {
|
||||
setAutoCaptionSettings((prev) => ({ ...prev, enabled: false }));
|
||||
}, []);
|
||||
|
||||
const handleSaveAutoCaptionEdit = useCallback(
|
||||
(target: CaptionEditTarget, text: string) => {
|
||||
setAutoCaptions((captions) => updateCaptionCuesForEditedTarget(captions, target, text));
|
||||
toast.success(t("settings.captions.editSaved", "Caption updated"));
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const saveProject = useCallback(
|
||||
async (forceSaveAs: boolean, options?: SaveProjectOptions) => {
|
||||
clearPendingProjectAutosave();
|
||||
@@ -4878,6 +5182,111 @@ export default function VideoEditor() {
|
||||
className="flex items-center gap-2 justify-self-end pr-3"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<Popover open={presetPopoverOpen} onOpenChange={setPresetPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={t("editor.presets.open", "Open presets")}
|
||||
aria-label={t("editor.presets.open", "Open presets")}
|
||||
className="inline-flex items-center gap-1.5 bg-transparent p-0 text-sm font-medium tracking-tight text-foreground outline-none transition-opacity hover:opacity-80"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<BookmarkSimple weight="fill" className="h-4 w-4" />
|
||||
<span>{currentEditorPreset?.name ?? t("editor.presets.label", "Presets")}</span>
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
className="w-[300px] rounded-2xl border border-foreground/10 bg-editor-surface-alt p-3 shadow-xl"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSavePresetSubmit();
|
||||
}}
|
||||
className="space-y-2"
|
||||
>
|
||||
<p className="text-[11px] font-medium text-foreground">
|
||||
{t("editor.presets.saveCurrentAs", "Save current preset as")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={presetNameDraft}
|
||||
onChange={(event) => setPresetNameDraft(event.target.value)}
|
||||
className="h-9 rounded-xl border-foreground/10 bg-background/70 text-sm"
|
||||
placeholder={t("editor.presets.namePlaceholder", "Preset name")}
|
||||
aria-label={t("editor.presets.namePlaceholder", "Preset name")}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-9 rounded-xl bg-[#2563EB] px-3 text-white hover:bg-[#1d4ed8]"
|
||||
>
|
||||
{t("common.actions.save", "Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-medium text-foreground">
|
||||
{t("editor.presets.savedList", "Saved presets")}
|
||||
</p>
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto pr-1 custom-scrollbar">
|
||||
{editorPresets.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-foreground/10 px-3 py-4 text-center text-[11px] text-muted-foreground">
|
||||
{t("editor.presets.empty", "No presets yet.")}
|
||||
</div>
|
||||
) : (
|
||||
editorPresets.map((preset) => {
|
||||
const isActive = preset.id === currentEditorPreset?.id;
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-xl border px-2 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "border-[#2563EB]/20 bg-[#2563EB]/10 text-foreground"
|
||||
: "border-foreground/8 bg-foreground/[0.03] text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApplyEditorPreset(preset.id)}
|
||||
className="flex min-w-0 flex-1 items-center justify-between text-left"
|
||||
>
|
||||
<span className="truncate pr-3">{preset.name}</span>
|
||||
{isActive ? <Check className="h-3.5 w-3.5 shrink-0 text-[#2563EB]" /> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteEditorPreset(preset.id)}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-foreground/8 hover:text-foreground"
|
||||
aria-label={t(
|
||||
"editor.presets.deleteAriaLabel",
|
||||
"Delete preset {{name}}",
|
||||
{ name: preset.name },
|
||||
)}
|
||||
title={t(
|
||||
"editor.presets.deleteAriaLabel",
|
||||
"Delete preset {{name}}",
|
||||
{ name: preset.name },
|
||||
)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DropdownMenu
|
||||
open={showExportDropdown}
|
||||
onOpenChange={setShowExportDropdown}
|
||||
@@ -4887,7 +5296,7 @@ export default function VideoEditor() {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleOpenExportDropdown}
|
||||
className="inline-flex h-8 min-w-[112px] items-center justify-center gap-2 rounded-[5px] bg-[#2563EB] px-4.5 text-white transition-colors hover:bg-[#2563EB]/92"
|
||||
className="ml-3 inline-flex h-8 min-w-[112px] items-center justify-center gap-2 rounded-[5px] bg-[#2563EB] px-4.5 text-white transition-colors hover:bg-[#2563EB]/92"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold tracking-tight">
|
||||
@@ -5440,6 +5849,7 @@ export default function VideoEditor() {
|
||||
annotationRegions={annotationRegions}
|
||||
autoCaptions={autoCaptions}
|
||||
autoCaptionSettings={autoCaptionSettings}
|
||||
onEditAutoCaption={handleSaveAutoCaptionEdit}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
onAnnotationPositionChange={
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useI18n } from "@/contexts/I18nContext";
|
||||
import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath";
|
||||
import { clampMediaTimeToDuration, getMediaSyncPlaybackRate } from "@/lib/mediaTiming";
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
DEFAULT_WALLPAPER_RELATIVE_PATH,
|
||||
isVideoWallpaperSource,
|
||||
} from "@/lib/wallpapers";
|
||||
import { type CaptionEditTarget, normalizeCaptionEditText } from "./captionEditing";
|
||||
import { buildActiveCaptionLayout } from "./captionLayout";
|
||||
import {
|
||||
CAPTION_FONT_WEIGHT,
|
||||
@@ -254,6 +256,7 @@ interface VideoPlaybackProps {
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
autoCaptions?: CaptionCue[];
|
||||
autoCaptionSettings?: AutoCaptionSettings;
|
||||
onEditAutoCaption?: (target: CaptionEditTarget, text: string) => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
|
||||
@@ -272,6 +275,11 @@ interface VideoPlaybackProps {
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
type CaptionEditSession = {
|
||||
target: CaptionEditTarget;
|
||||
draft: string;
|
||||
};
|
||||
|
||||
export interface VideoPlaybackRef {
|
||||
video: HTMLVideoElement | null;
|
||||
app: Application | null;
|
||||
@@ -324,6 +332,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
annotationRegions = [],
|
||||
autoCaptions = [],
|
||||
autoCaptionSettings,
|
||||
onEditAutoCaption,
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
onAnnotationPositionChange,
|
||||
@@ -343,6 +352,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useI18n();
|
||||
const editCurrentCaptionLabel = t("settings.captions.editCurrent", "Edit current caption");
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const appRef = useRef<Application | null>(null);
|
||||
@@ -359,6 +370,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const webcamBubbleRef = useRef<HTMLDivElement | null>(null);
|
||||
const webcamBubbleInnerRef = useRef<HTMLDivElement | null>(null);
|
||||
const captionBoxRef = useRef<HTMLDivElement | null>(null);
|
||||
const captionEditInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const captionEditSessionRef = useRef<CaptionEditSession | null>(null);
|
||||
const [captionEditSession, setCaptionEditSession] = useState<CaptionEditSession | null>(
|
||||
null,
|
||||
);
|
||||
const currentTimeRef = useRef(0);
|
||||
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
|
||||
const selectedZoomIdRef = useRef<string | null>(null);
|
||||
@@ -471,6 +487,148 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
measureText: (text) => measurementContext.measureText(text).width,
|
||||
});
|
||||
}, [autoCaptionSettings, autoCaptions, currentTime]);
|
||||
const activeCaptionEditTarget = activeCaptionLayout?.editTarget ?? null;
|
||||
const activeCaptionEditTargetId = activeCaptionEditTarget?.id ?? null;
|
||||
const isCaptionEditing = captionEditSession !== null;
|
||||
const captionEditDraft = captionEditSession?.draft ?? "";
|
||||
const captionEditTargetId = captionEditSession?.target.id ?? null;
|
||||
const captionEditTextMetrics = useMemo(() => {
|
||||
if (!captionEditSession || !autoCaptionSettings || typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const overlayWidth = overlayRef.current?.clientWidth || 960;
|
||||
const fontSize = getCaptionScaledFontSize(
|
||||
autoCaptionSettings.fontSize,
|
||||
overlayWidth,
|
||||
autoCaptionSettings.maxWidth,
|
||||
);
|
||||
const maxTextWidthPx = getCaptionTextMaxWidth(
|
||||
overlayWidth,
|
||||
autoCaptionSettings.maxWidth,
|
||||
fontSize,
|
||||
);
|
||||
const measurementCanvas = document.createElement("canvas");
|
||||
const measurementContext = measurementCanvas.getContext("2d");
|
||||
if (!measurementContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
measurementContext.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${getDefaultCaptionFontFamily()}`;
|
||||
const measuredWidth = Math.max(
|
||||
...captionEditSession.draft
|
||||
.split(/\r?\n/)
|
||||
.map((line) => measurementContext.measureText(line || " ").width),
|
||||
);
|
||||
|
||||
return {
|
||||
fontSize,
|
||||
maxTextWidthPx,
|
||||
widthPx: Math.ceil(
|
||||
Math.min(maxTextWidthPx, Math.max(fontSize * 2, measuredWidth + 2)),
|
||||
),
|
||||
};
|
||||
}, [autoCaptionSettings, captionEditSession]);
|
||||
const captionEditSizeKey = captionEditSession
|
||||
? `${captionEditTextMetrics?.widthPx ?? 0}:${captionEditDraft}`
|
||||
: "";
|
||||
|
||||
const beginCaptionEdit = useCallback(() => {
|
||||
if (!activeCaptionLayout?.editTarget || !onEditAutoCaption) {
|
||||
return;
|
||||
}
|
||||
|
||||
videoRef.current?.pause();
|
||||
onPlayStateChange(false);
|
||||
const nextSession = {
|
||||
target: activeCaptionLayout.editTarget,
|
||||
draft: activeCaptionLayout.editTarget.text,
|
||||
};
|
||||
captionEditSessionRef.current = nextSession;
|
||||
setCaptionEditSession(nextSession);
|
||||
}, [activeCaptionLayout, onEditAutoCaption, onPlayStateChange]);
|
||||
|
||||
const commitCaptionEdit = useCallback(() => {
|
||||
const session = captionEditSessionRef.current;
|
||||
if (!session || !onEditAutoCaption) {
|
||||
captionEditSessionRef.current = null;
|
||||
setCaptionEditSession(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedDraft = normalizeCaptionEditText(session.draft);
|
||||
captionEditSessionRef.current = null;
|
||||
if (!normalizedDraft) {
|
||||
setCaptionEditSession(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedDraft !== normalizeCaptionEditText(session.target.text)) {
|
||||
onEditAutoCaption(session.target, session.draft);
|
||||
}
|
||||
setCaptionEditSession(null);
|
||||
}, [onEditAutoCaption]);
|
||||
|
||||
const cancelCaptionEdit = useCallback(() => {
|
||||
captionEditSessionRef.current = null;
|
||||
setCaptionEditSession(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCaptionEditTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCaptionEditSession((session) => {
|
||||
if (!session || session.target.id === activeCaptionEditTargetId) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const nextSession = {
|
||||
...session,
|
||||
target: activeCaptionEditTarget,
|
||||
};
|
||||
captionEditSessionRef.current = nextSession;
|
||||
return nextSession;
|
||||
});
|
||||
}, [activeCaptionEditTarget, activeCaptionEditTargetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!captionEditTargetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const input = captionEditInputRef.current;
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.focus();
|
||||
const cursorPosition = input.value.length;
|
||||
input.setSelectionRange(cursorPosition, cursorPosition);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [captionEditTargetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!captionEditSizeKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const input = captionEditInputRef.current;
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.style.height = "auto";
|
||||
input.style.height = `${input.scrollHeight}px`;
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [captionEditSizeKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const captionBox = captionBoxRef.current;
|
||||
@@ -483,6 +641,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (isCaptionEditing) {
|
||||
captionBox.dataset.editingCaption = captionEditSizeKey;
|
||||
} else {
|
||||
delete captionBox.dataset.editingCaption;
|
||||
}
|
||||
|
||||
const width = captionBox.offsetWidth;
|
||||
const height = captionBox.offsetHeight;
|
||||
if (width <= 0 || height <= 0) {
|
||||
@@ -507,7 +671,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [activeCaptionLayout, autoCaptionSettings]);
|
||||
}, [activeCaptionLayout, autoCaptionSettings, captionEditSizeKey, isCaptionEditing]);
|
||||
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
|
||||
|
||||
const applyWebcamBubbleLayout = useCallback(
|
||||
@@ -1020,7 +1184,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
: clampMediaTimeToDuration(clipTimelineTime, videoDuration);
|
||||
|
||||
const activeSpeedRegion = speedRegionsRef.current.find(
|
||||
(region) => currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs,
|
||||
(region) =>
|
||||
currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs,
|
||||
);
|
||||
const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
|
||||
const syncedPlaybackRate = getMediaSyncPlaybackRate({
|
||||
@@ -2368,7 +2533,34 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role={
|
||||
onEditAutoCaption && !captionEditSession
|
||||
? "button"
|
||||
: undefined
|
||||
}
|
||||
tabIndex={
|
||||
onEditAutoCaption && !captionEditSession ? 0 : undefined
|
||||
}
|
||||
aria-label={
|
||||
onEditAutoCaption && !captionEditSession
|
||||
? editCurrentCaptionLabel
|
||||
: undefined
|
||||
}
|
||||
ref={captionBoxRef}
|
||||
onClick={() => {
|
||||
if (!captionEditSession) {
|
||||
beginCaptionEdit();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!onEditAutoCaption || captionEditSession) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
beginCaptionEdit();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: `rgba(0, 0, 0, ${autoCaptionSettings.backgroundOpacity})`,
|
||||
fontFamily: getDefaultCaptionFontFamily(),
|
||||
@@ -2406,42 +2598,137 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
),
|
||||
)}px`,
|
||||
boxSizing: "border-box",
|
||||
cursor:
|
||||
onEditAutoCaption && !captionEditSession
|
||||
? "text"
|
||||
: undefined,
|
||||
pointerEvents: onEditAutoCaption ? "auto" : undefined,
|
||||
}}
|
||||
>
|
||||
{activeCaptionLayout.visibleLines.map((line) => (
|
||||
<div
|
||||
key={`${activeCaptionLayout.blockKey}-${line.startWordIndex}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
flexWrap: "nowrap",
|
||||
whiteSpace: "nowrap",
|
||||
{captionEditSession ? (
|
||||
<textarea
|
||||
ref={captionEditInputRef}
|
||||
value={captionEditSession.draft}
|
||||
onChange={(event) => {
|
||||
const draft = event.target.value;
|
||||
setCaptionEditSession((session) => {
|
||||
const nextSession = session
|
||||
? {
|
||||
...session,
|
||||
draft,
|
||||
}
|
||||
: session;
|
||||
captionEditSessionRef.current = nextSession;
|
||||
return nextSession;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{line.words.map((word) => {
|
||||
const visualState = getCaptionWordVisualState(
|
||||
activeCaptionLayout.hasWordTimings,
|
||||
word.state,
|
||||
);
|
||||
onBlur={commitCaptionEdit}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
cancelCaptionEdit();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
rows={Math.max(
|
||||
1,
|
||||
activeCaptionLayout.visibleLines.length,
|
||||
)}
|
||||
aria-label={editCurrentCaptionLabel}
|
||||
style={{
|
||||
display: "block",
|
||||
width: `${
|
||||
captionEditTextMetrics?.widthPx ??
|
||||
Math.max(
|
||||
48,
|
||||
activeCaptionLayout.visibleLines.reduce(
|
||||
(width, line) =>
|
||||
Math.max(width, line.width),
|
||||
0,
|
||||
),
|
||||
)
|
||||
}px`,
|
||||
maxWidth: `${
|
||||
captionEditTextMetrics?.maxTextWidthPx ??
|
||||
getCaptionTextMaxWidth(
|
||||
overlayRef.current?.clientWidth || 960,
|
||||
autoCaptionSettings.maxWidth,
|
||||
getCaptionScaledFontSize(
|
||||
autoCaptionSettings.fontSize,
|
||||
overlayRef.current?.clientWidth ||
|
||||
960,
|
||||
autoCaptionSettings.maxWidth,
|
||||
),
|
||||
)
|
||||
}px`,
|
||||
minHeight: `${
|
||||
Math.max(
|
||||
1,
|
||||
activeCaptionLayout.visibleLines.length,
|
||||
) *
|
||||
getCaptionScaledFontSize(
|
||||
autoCaptionSettings.fontSize,
|
||||
overlayRef.current?.clientWidth || 960,
|
||||
autoCaptionSettings.maxWidth,
|
||||
) *
|
||||
CAPTION_LINE_HEIGHT
|
||||
}px`,
|
||||
resize: "none",
|
||||
border: "0",
|
||||
outline: "0",
|
||||
padding: "0",
|
||||
margin: "0",
|
||||
overflow: "hidden",
|
||||
background: "transparent",
|
||||
color: autoCaptionSettings.textColor,
|
||||
font: "inherit",
|
||||
fontWeight: "inherit",
|
||||
lineHeight: "inherit",
|
||||
textAlign: "center",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
activeCaptionLayout.visibleLines.map((line) => (
|
||||
<div
|
||||
key={`${activeCaptionLayout.blockKey}-${line.startWordIndex}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
flexWrap: "nowrap",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{line.words.map((word) => {
|
||||
const visualState =
|
||||
getCaptionWordVisualState(
|
||||
activeCaptionLayout.hasWordTimings,
|
||||
word.state,
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`${activeCaptionLayout.blockKey}-${word.index}`}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
whiteSpace: "pre",
|
||||
color: visualState.isInactive
|
||||
? autoCaptionSettings.inactiveTextColor
|
||||
: autoCaptionSettings.textColor,
|
||||
opacity: visualState.opacity,
|
||||
}}
|
||||
>
|
||||
{`${word.leadingSpace ? " " : ""}${word.text}`}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
return (
|
||||
<span
|
||||
key={`${activeCaptionLayout.blockKey}-${word.index}`}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
whiteSpace: "pre",
|
||||
color: visualState.isInactive
|
||||
? autoCaptionSettings.inactiveTextColor
|
||||
: autoCaptionSettings.textColor,
|
||||
opacity: visualState.opacity,
|
||||
}}
|
||||
>
|
||||
{`${word.leadingSpace ? " " : ""}${word.text}`}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type CaptionEditTarget,
|
||||
normalizeCaptionEditText,
|
||||
updateCaptionCuesForEditedTarget,
|
||||
} from "./captionEditing";
|
||||
import { buildActiveCaptionLayout } from "./captionLayout";
|
||||
import { type CaptionCue, DEFAULT_AUTO_CAPTION_SETTINGS } from "./types";
|
||||
|
||||
const visibleTarget: CaptionEditTarget = {
|
||||
id: "visible-page",
|
||||
startMs: 1_000,
|
||||
endMs: 2_400,
|
||||
text: "Hello Hello 你们好啊",
|
||||
words: [
|
||||
{
|
||||
cueId: "a",
|
||||
cueWordIndex: 0,
|
||||
startMs: 1_000,
|
||||
endMs: 1_500,
|
||||
text: "Hello",
|
||||
leadingSpace: false,
|
||||
},
|
||||
{
|
||||
cueId: "a",
|
||||
cueWordIndex: 1,
|
||||
startMs: 1_500,
|
||||
endMs: 2_000,
|
||||
text: "Hello",
|
||||
leadingSpace: true,
|
||||
},
|
||||
{
|
||||
cueId: "b",
|
||||
cueWordIndex: 0,
|
||||
startMs: 2_000,
|
||||
endMs: 2_400,
|
||||
text: "你们好啊",
|
||||
leadingSpace: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("captionEditing", () => {
|
||||
it("normalizes edited caption text", () => {
|
||||
expect(normalizeCaptionEditText(" hello \n edited\tcaption ")).toBe(
|
||||
"hello edited caption",
|
||||
);
|
||||
expect(normalizeCaptionEditText(" \n\t ")).toBe("");
|
||||
});
|
||||
|
||||
it("keeps text-only captions text-only after editing", () => {
|
||||
const updated = updateCaptionCuesForEditedTarget(
|
||||
[
|
||||
{ id: "a", startMs: 1_000, endMs: 2_000, text: "Hello Hello" },
|
||||
{ id: "b", startMs: 2_000, endMs: 3_000, text: "你们好啊 这个是我的屏幕" },
|
||||
],
|
||||
visibleTarget,
|
||||
"Hi 大家好",
|
||||
);
|
||||
|
||||
expect(updated.map((caption) => caption.text)).toEqual(["Hi", "大家好 这个是我的屏幕"]);
|
||||
expect(updated.every((caption) => caption.words === undefined)).toBe(true);
|
||||
|
||||
const layout = buildActiveCaptionLayout({
|
||||
cues: updated,
|
||||
timeMs: 1_500,
|
||||
settings: DEFAULT_AUTO_CAPTION_SETTINGS,
|
||||
maxWidthPx: 500,
|
||||
measureText: (text) => text.length * 10,
|
||||
});
|
||||
expect(layout?.hasWordTimings).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves cue identity and timing when editing captions with word timings", () => {
|
||||
const cues: CaptionCue[] = [
|
||||
{
|
||||
id: "a",
|
||||
startMs: 1_000,
|
||||
endMs: 2_000,
|
||||
text: "Hello Hello",
|
||||
words: [
|
||||
{ text: "Hello", startMs: 1_000, endMs: 1_500 },
|
||||
{ text: "Hello", startMs: 1_500, endMs: 2_000, leadingSpace: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
startMs: 2_000,
|
||||
endMs: 3_000,
|
||||
text: "你们好啊 这个是我的屏幕",
|
||||
words: [
|
||||
{ text: "你们好啊", startMs: 2_000, endMs: 2_400 },
|
||||
{ text: "这个是我的屏幕", startMs: 2_400, endMs: 3_000, leadingSpace: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const updated = updateCaptionCuesForEditedTarget(cues, visibleTarget, "Hi 大家好");
|
||||
|
||||
expect(updated.map((caption) => [caption.id, caption.startMs, caption.endMs])).toEqual([
|
||||
["a", 1_000, 2_000],
|
||||
["b", 2_000, 3_000],
|
||||
]);
|
||||
expect(updated[0].words).toEqual([{ text: "Hi", startMs: 1_000, endMs: 2_000 }]);
|
||||
expect(updated[1].words).toEqual([
|
||||
{ text: "大家好", startMs: 2_000, endMs: 2_400 },
|
||||
{ text: "这个是我的屏幕", startMs: 2_400, endMs: 3_000, leadingSpace: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not update captions when edited text is blank", () => {
|
||||
const cues: CaptionCue[] = [{ id: "a", startMs: 1_000, endMs: 2_000, text: "Hello Hello" }];
|
||||
|
||||
expect(updateCaptionCuesForEditedTarget(cues, visibleTarget, " \n\t ")).toBe(cues);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { CaptionCue, CaptionCueWord } from "./types";
|
||||
|
||||
export interface CaptionEditWordRef {
|
||||
cueId: string;
|
||||
cueWordIndex: number;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
text: string;
|
||||
leadingSpace: boolean;
|
||||
}
|
||||
|
||||
export interface CaptionEditTarget {
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
text: string;
|
||||
words: CaptionEditWordRef[];
|
||||
}
|
||||
|
||||
export function normalizeCaptionEditText(text: string) {
|
||||
return text.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function buildCaptionWordsForEditedText(
|
||||
text: string,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
): CaptionCueWord[] {
|
||||
const normalizedText = normalizeCaptionEditText(text);
|
||||
const tokens = normalizedText.match(/\S+/g) ?? [];
|
||||
const normalizedStartMs = Math.max(0, Math.round(startMs));
|
||||
const normalizedEndMs = Math.max(normalizedStartMs + 1, Math.round(endMs));
|
||||
const durationMs = normalizedEndMs - normalizedStartMs;
|
||||
|
||||
return tokens.map((token, index) => {
|
||||
const wordStartMs = Math.min(
|
||||
normalizedEndMs - 1,
|
||||
Math.max(
|
||||
normalizedStartMs,
|
||||
Math.round(normalizedStartMs + (durationMs * index) / tokens.length),
|
||||
),
|
||||
);
|
||||
const nextBoundaryMs =
|
||||
index === tokens.length - 1
|
||||
? normalizedEndMs
|
||||
: Math.round(normalizedStartMs + (durationMs * (index + 1)) / tokens.length);
|
||||
const wordEndMs = Math.min(normalizedEndMs, Math.max(wordStartMs + 1, nextBoundaryMs));
|
||||
|
||||
return {
|
||||
text: token,
|
||||
startMs: wordStartMs,
|
||||
endMs: wordEndMs,
|
||||
...(index > 0 ? { leadingSpace: true } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCaptionWords(cue: CaptionCue): CaptionCueWord[] {
|
||||
const sourceWords =
|
||||
Array.isArray(cue.words) && cue.words.length > 0
|
||||
? cue.words
|
||||
: buildCaptionWordsForEditedText(cue.text, cue.startMs, cue.endMs);
|
||||
|
||||
return sourceWords
|
||||
.filter((word): word is CaptionCueWord => Boolean(word && typeof word.text === "string"))
|
||||
.map((word) => {
|
||||
const startMs = Math.max(
|
||||
cue.startMs,
|
||||
Math.min(cue.endMs - 1, Math.round(word.startMs)),
|
||||
);
|
||||
const endMs = Math.max(startMs + 1, Math.min(cue.endMs, Math.round(word.endMs)));
|
||||
|
||||
return {
|
||||
text: normalizeCaptionEditText(word.text),
|
||||
startMs,
|
||||
endMs,
|
||||
...(word.leadingSpace ? { leadingSpace: true } : {}),
|
||||
};
|
||||
})
|
||||
.filter((word) => word.text.length > 0);
|
||||
}
|
||||
|
||||
function captionWordsToText(words: CaptionCueWord[]) {
|
||||
return words
|
||||
.map((word, index) => `${index > 0 && word.leadingSpace ? " " : ""}${word.text}`)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeCaptionWordSpacing(words: CaptionCueWord[]): CaptionCueWord[] {
|
||||
return words
|
||||
.slice()
|
||||
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs)
|
||||
.map((word, index) => ({
|
||||
text: word.text,
|
||||
startMs: word.startMs,
|
||||
endMs: word.endMs,
|
||||
...(index > 0 ? { leadingSpace: true } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function shouldPreserveCaptionWords(cue: CaptionCue) {
|
||||
return Array.isArray(cue.words) && cue.words.length > 0;
|
||||
}
|
||||
|
||||
export function updateCaptionCuesForEditedTarget(
|
||||
cues: CaptionCue[],
|
||||
target: CaptionEditTarget,
|
||||
text: string,
|
||||
): CaptionCue[] {
|
||||
const normalizedText = normalizeCaptionEditText(text);
|
||||
if (!normalizedText || target.words.length === 0) {
|
||||
return cues;
|
||||
}
|
||||
|
||||
const targetWordsByCue = new Map<string, CaptionEditWordRef[]>();
|
||||
for (const word of target.words) {
|
||||
const words = targetWordsByCue.get(word.cueId) ?? [];
|
||||
words.push(word);
|
||||
targetWordsByCue.set(word.cueId, words);
|
||||
}
|
||||
|
||||
const tokens = normalizedText.match(/\S+/g) ?? [];
|
||||
const targetCueIds = new Set(targetWordsByCue.keys());
|
||||
const cueSegments = cues
|
||||
.filter((cue) => targetCueIds.has(cue.id))
|
||||
.map((cue) => {
|
||||
const words = targetWordsByCue.get(cue.id) ?? [];
|
||||
return {
|
||||
cue,
|
||||
startMs: Math.min(...words.map((word) => word.startMs)),
|
||||
endMs: Math.max(...words.map((word) => word.endMs)),
|
||||
};
|
||||
})
|
||||
.filter((segment) => Number.isFinite(segment.startMs) && Number.isFinite(segment.endMs));
|
||||
const editedWordsByCue = new Map<string, CaptionCueWord[]>();
|
||||
const tokenCountsByCue = distributeEditedTokensAcrossCueSegments(tokens.length, cueSegments);
|
||||
let tokenCursor = 0;
|
||||
|
||||
for (const segment of cueSegments) {
|
||||
const tokenCount = tokenCountsByCue.get(segment.cue.id) ?? 0;
|
||||
if (tokenCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const segmentTokens = tokens.slice(tokenCursor, tokenCursor + tokenCount);
|
||||
tokenCursor += tokenCount;
|
||||
editedWordsByCue.set(
|
||||
segment.cue.id,
|
||||
buildCaptionWordsForEditedText(segmentTokens.join(" "), segment.startMs, segment.endMs),
|
||||
);
|
||||
}
|
||||
|
||||
return cues.map((cue) => {
|
||||
const targetWords = targetWordsByCue.get(cue.id);
|
||||
if (!targetWords) {
|
||||
return cue;
|
||||
}
|
||||
|
||||
const targetIndexes = new Set(targetWords.map((word) => word.cueWordIndex));
|
||||
const existingWords = normalizeCaptionWords(cue);
|
||||
const keptWords = existingWords.filter((_, index) => !targetIndexes.has(index));
|
||||
const nextWords = normalizeCaptionWordSpacing([
|
||||
...keptWords,
|
||||
...(editedWordsByCue.get(cue.id) ?? []),
|
||||
]);
|
||||
const shouldKeepWords = shouldPreserveCaptionWords(cue);
|
||||
|
||||
return {
|
||||
id: cue.id,
|
||||
startMs: cue.startMs,
|
||||
endMs: cue.endMs,
|
||||
text: captionWordsToText(nextWords),
|
||||
...(shouldKeepWords && nextWords.length > 0 ? { words: nextWords } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function distributeEditedTokensAcrossCueSegments(
|
||||
tokenCount: number,
|
||||
segments: Array<{ cue: CaptionCue; startMs: number; endMs: number }>,
|
||||
) {
|
||||
const tokenCountsByCue = new Map<string, number>();
|
||||
if (tokenCount <= 0 || segments.length === 0) {
|
||||
return tokenCountsByCue;
|
||||
}
|
||||
|
||||
if (tokenCount < segments.length) {
|
||||
const largestSegments = [...segments]
|
||||
.sort((a, b) => b.endMs - b.startMs - (a.endMs - a.startMs))
|
||||
.slice(0, tokenCount);
|
||||
const selectedCueIds = new Set(largestSegments.map((segment) => segment.cue.id));
|
||||
for (const segment of segments) {
|
||||
tokenCountsByCue.set(segment.cue.id, selectedCueIds.has(segment.cue.id) ? 1 : 0);
|
||||
}
|
||||
return tokenCountsByCue;
|
||||
}
|
||||
|
||||
const baseTokenCount = 1;
|
||||
const remainingTokens = tokenCount - segments.length;
|
||||
const totalDuration = Math.max(
|
||||
1,
|
||||
segments.reduce(
|
||||
(total, segment) => total + Math.max(1, segment.endMs - segment.startMs),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const weightedCounts = segments.map((segment) => {
|
||||
const exactCount =
|
||||
(Math.max(1, segment.endMs - segment.startMs) / totalDuration) * remainingTokens;
|
||||
const extraCount = Math.floor(exactCount);
|
||||
return {
|
||||
segment,
|
||||
count: baseTokenCount + extraCount,
|
||||
remainder: exactCount - extraCount,
|
||||
};
|
||||
});
|
||||
let assignedTokens = weightedCounts.reduce((total, item) => total + item.count, 0);
|
||||
|
||||
for (const item of [...weightedCounts].sort((a, b) => b.remainder - a.remainder)) {
|
||||
if (assignedTokens >= tokenCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
item.count += 1;
|
||||
assignedTokens += 1;
|
||||
}
|
||||
|
||||
for (const item of weightedCounts) {
|
||||
tokenCountsByCue.set(item.segment.cue.id, item.count);
|
||||
}
|
||||
|
||||
return tokenCountsByCue;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CaptionEditTarget } from "./captionEditing";
|
||||
import type {
|
||||
AutoCaptionAnimation,
|
||||
AutoCaptionSettings,
|
||||
@@ -8,12 +9,15 @@ import type {
|
||||
export type CaptionWordState = "spoken" | "active" | "upcoming";
|
||||
|
||||
export interface CaptionWordLayout {
|
||||
cueId: string;
|
||||
cueWordIndex: number;
|
||||
text: string;
|
||||
index: number;
|
||||
forcedBreakBefore: boolean;
|
||||
leadingSpace: boolean;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
hasRealTiming: boolean;
|
||||
state: CaptionWordState;
|
||||
}
|
||||
|
||||
@@ -37,6 +41,7 @@ export interface ActiveCaptionLayout {
|
||||
hasWordTimings: boolean;
|
||||
activeWordIndex: number;
|
||||
activeWordProgress: number;
|
||||
editTarget: CaptionEditTarget;
|
||||
visiblePageIndex: number;
|
||||
opacity: number;
|
||||
translateY: number;
|
||||
@@ -45,6 +50,7 @@ export interface ActiveCaptionLayout {
|
||||
|
||||
type CaptionSourceWord = {
|
||||
cueId: string;
|
||||
cueWordIndex: number;
|
||||
text: string;
|
||||
forcedBreakBefore: boolean;
|
||||
leadingSpace?: boolean;
|
||||
@@ -77,6 +83,7 @@ function splitCaptionWordsFromText(text: string) {
|
||||
.forEach((word, wordIndex) => {
|
||||
words.push({
|
||||
cueId: "",
|
||||
cueWordIndex: words.length,
|
||||
text: word,
|
||||
forcedBreakBefore: lineIndex > 0 && wordIndex === 0,
|
||||
});
|
||||
@@ -92,8 +99,9 @@ function splitCaptionWords(cue: CaptionCue) {
|
||||
.filter((word): word is CaptionCueWord =>
|
||||
Boolean(word && typeof word.text === "string"),
|
||||
)
|
||||
.map((word) => ({
|
||||
.map((word, cueWordIndex) => ({
|
||||
cueId: cue.id,
|
||||
cueWordIndex,
|
||||
text: word.text.trim(),
|
||||
forcedBreakBefore: false,
|
||||
leadingSpace: Boolean(word.leadingSpace),
|
||||
@@ -103,7 +111,10 @@ function splitCaptionWords(cue: CaptionCue) {
|
||||
.filter((word) => word.text.length > 0);
|
||||
}
|
||||
|
||||
return splitCaptionWordsFromText(cue.text);
|
||||
return splitCaptionWordsFromText(cue.text).map((word) => ({
|
||||
...word,
|
||||
cueId: cue.id,
|
||||
}));
|
||||
}
|
||||
|
||||
function getActiveCaptionCue(cues: CaptionCue[], timeMs: number) {
|
||||
@@ -119,6 +130,7 @@ function getActiveCaptionCue(cues: CaptionCue[], timeMs: number) {
|
||||
function flattenCaptionWords(cues: CaptionCue[]) {
|
||||
const flattened: Array<{
|
||||
cueId: string;
|
||||
cueWordIndex: number;
|
||||
text: string;
|
||||
forcedBreakBefore: boolean;
|
||||
leadingSpace: boolean;
|
||||
@@ -160,6 +172,7 @@ function flattenCaptionWords(cues: CaptionCue[]) {
|
||||
|
||||
flattened.push({
|
||||
cueId: cue.id,
|
||||
cueWordIndex: word.cueWordIndex,
|
||||
text: word.text,
|
||||
forcedBreakBefore:
|
||||
word.forcedBreakBefore || (wordIndex === 0 && shouldForceCueBreak),
|
||||
@@ -380,6 +393,18 @@ function getVisibleCaptionPageIndex(pages: CaptionPageLayout[], timeMs: number)
|
||||
return -1;
|
||||
}
|
||||
|
||||
function getVisibleCaptionText(lines: CaptionLineLayout[]) {
|
||||
return lines
|
||||
.map((line) =>
|
||||
line.words
|
||||
.map((word, index) => `${index > 0 && word.leadingSpace ? " " : ""}${word.text}`)
|
||||
.join("")
|
||||
.trim(),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function buildActiveCaptionLayout(options: {
|
||||
cues: CaptionCue[];
|
||||
timeMs: number;
|
||||
@@ -392,34 +417,32 @@ export function buildActiveCaptionLayout(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasWordTimings = sourceWords.every((word) => word.hasRealTiming);
|
||||
|
||||
let activeWordIndex = -1;
|
||||
if (hasWordTimings) {
|
||||
activeWordIndex = sourceWords.findIndex(
|
||||
(word) => options.timeMs >= word.startMs && options.timeMs < word.endMs,
|
||||
);
|
||||
if (activeWordIndex < 0) {
|
||||
activeWordIndex = sourceWords.findIndex((word) => options.timeMs < word.startMs);
|
||||
activeWordIndex =
|
||||
activeWordIndex < 0
|
||||
? sourceWords.length - 1
|
||||
: clamp(activeWordIndex - 1, 0, sourceWords.length - 1);
|
||||
}
|
||||
activeWordIndex = sourceWords.findIndex(
|
||||
(word) => options.timeMs >= word.startMs && options.timeMs < word.endMs,
|
||||
);
|
||||
if (activeWordIndex < 0) {
|
||||
activeWordIndex = sourceWords.findIndex((word) => options.timeMs < word.startMs);
|
||||
activeWordIndex =
|
||||
activeWordIndex < 0
|
||||
? sourceWords.length - 1
|
||||
: clamp(activeWordIndex - 1, 0, sourceWords.length - 1);
|
||||
}
|
||||
const maxRows = clamp(Math.round(options.settings.maxRows || 1), 1, 4);
|
||||
|
||||
const words: CaptionWordLayout[] = sourceWords.map((word, index) => {
|
||||
return {
|
||||
cueId: word.cueId,
|
||||
cueWordIndex: word.cueWordIndex,
|
||||
text: word.text,
|
||||
index,
|
||||
forcedBreakBefore: word.forcedBreakBefore,
|
||||
leadingSpace: word.leadingSpace,
|
||||
startMs: word.startMs,
|
||||
endMs: word.endMs,
|
||||
state: !hasWordTimings
|
||||
? "spoken"
|
||||
: index < activeWordIndex
|
||||
hasRealTiming: word.hasRealTiming,
|
||||
state:
|
||||
index < activeWordIndex
|
||||
? "spoken"
|
||||
: index === activeWordIndex
|
||||
? "active"
|
||||
@@ -461,6 +484,8 @@ export function buildActiveCaptionLayout(options: {
|
||||
const animationStartMs = visiblePage?.startMs ?? sourceWords[0].startMs;
|
||||
const animationEndMs = visiblePage?.endMs ?? sourceWords[sourceWords.length - 1].endMs;
|
||||
const pageStartWordIndex = visibleLines[0]?.startWordIndex ?? 0;
|
||||
const visibleWords = visibleLines.flatMap((line) => line.words);
|
||||
const visibleHasWordTimings = visibleWords.every((word) => word.hasRealTiming);
|
||||
const pageCueId = sourceWords[pageStartWordIndex]?.cueId ?? sourceWords[0].cueId;
|
||||
const activeCue =
|
||||
getActiveCaptionCue(options.cues, options.timeMs) ??
|
||||
@@ -478,9 +503,25 @@ export function buildActiveCaptionLayout(options: {
|
||||
cue: activeCue,
|
||||
blockKey: `${Math.round(animationStartMs)}-${Math.round(animationEndMs)}`,
|
||||
visibleLines,
|
||||
hasWordTimings,
|
||||
hasWordTimings: visibleHasWordTimings,
|
||||
activeWordIndex,
|
||||
activeWordProgress,
|
||||
editTarget: {
|
||||
id: `${Math.round(animationStartMs)}-${Math.round(animationEndMs)}:${visibleWords
|
||||
.map((word) => `${word.cueId}:${word.cueWordIndex}`)
|
||||
.join("|")}`,
|
||||
startMs: animationStartMs,
|
||||
endMs: animationEndMs,
|
||||
text: getVisibleCaptionText(visibleLines),
|
||||
words: visibleWords.map((word) => ({
|
||||
cueId: word.cueId,
|
||||
cueWordIndex: word.cueWordIndex,
|
||||
startMs: word.startMs,
|
||||
endMs: word.endMs,
|
||||
text: word.text,
|
||||
leadingSpace: word.leadingSpace,
|
||||
})),
|
||||
},
|
||||
visiblePageIndex,
|
||||
opacity: animation.opacity,
|
||||
translateY: animation.translateY,
|
||||
|
||||
@@ -3,10 +3,14 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_EDITOR_PREFERENCES,
|
||||
EDITOR_PREFERENCES_STORAGE_KEY,
|
||||
EDITOR_PRESETS_STORAGE_KEY,
|
||||
loadEditorPresets,
|
||||
loadEditorPreferences,
|
||||
normalizeEditorPreferences,
|
||||
saveEditorPresets,
|
||||
saveEditorPreferences,
|
||||
} from "./editorPreferences";
|
||||
import { DEFAULT_AUTO_CAPTION_SETTINGS } from "./types";
|
||||
|
||||
function createStorageMock(initialValues: Record<string, string> = {}): Storage {
|
||||
const store = new Map(Object.entries(initialValues));
|
||||
@@ -318,4 +322,42 @@ describe("editorPreferences", () => {
|
||||
whisperModelPath: "/Users/test/models/ggml-small.bin",
|
||||
});
|
||||
});
|
||||
|
||||
it("saves editor presets and reports success", () => {
|
||||
const localStorage = createStorageMock();
|
||||
vi.stubGlobal("localStorage", localStorage);
|
||||
|
||||
expect(
|
||||
saveEditorPresets([
|
||||
{
|
||||
id: "preset-1",
|
||||
name: " Demo Preset ",
|
||||
createdAt: "2026-05-01T00:00:00.000Z",
|
||||
updatedAt: "2026-05-01T00:00:00.000Z",
|
||||
snapshot: {
|
||||
...DEFAULT_EDITOR_PREFERENCES,
|
||||
autoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS,
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
|
||||
expect(localStorage.getItem(EDITOR_PRESETS_STORAGE_KEY)).not.toBeNull();
|
||||
expect(loadEditorPresets()).toMatchObject([
|
||||
{
|
||||
id: "preset-1",
|
||||
name: "Demo Preset",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns false when preset persistence fails", () => {
|
||||
const localStorage = createStorageMock();
|
||||
localStorage.setItem = () => {
|
||||
throw new Error("quota exceeded");
|
||||
};
|
||||
vi.stubGlobal("localStorage", localStorage);
|
||||
|
||||
expect(saveEditorPresets([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,22 @@ type PersistedEditorControls = Pick<
|
||||
|
||||
type PartialEditorControls = Partial<PersistedEditorControls>;
|
||||
|
||||
type PresetAutoCaptionSettings = ProjectEditorState["autoCaptionSettings"];
|
||||
|
||||
export interface EditorPresetSnapshot extends PersistedEditorControls {
|
||||
autoCaptionSettings: PresetAutoCaptionSettings;
|
||||
whisperExecutablePath: string | null;
|
||||
whisperModelPath: string | null;
|
||||
}
|
||||
|
||||
export interface EditorPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
snapshot: EditorPresetSnapshot;
|
||||
}
|
||||
|
||||
export interface EditorPreferences extends PersistedEditorControls {
|
||||
customAspectWidth: string;
|
||||
customAspectHeight: string;
|
||||
@@ -58,6 +74,7 @@ export interface EditorPreferences extends PersistedEditorControls {
|
||||
}
|
||||
|
||||
export const EDITOR_PREFERENCES_STORAGE_KEY = "recordly.editor.preferences";
|
||||
export const EDITOR_PRESETS_STORAGE_KEY = "recordly.editor.presets";
|
||||
|
||||
const DEFAULT_EDITOR_CONTROLS = normalizeProjectEditor({});
|
||||
|
||||
@@ -144,6 +161,88 @@ function normalizeNullablePath(value: unknown): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizePresetAutoCaptionSettings(value: unknown): PresetAutoCaptionSettings {
|
||||
return normalizeProjectEditor({
|
||||
autoCaptionSettings:
|
||||
value && typeof value === "object"
|
||||
? (value as PresetAutoCaptionSettings)
|
||||
: undefined,
|
||||
}).autoCaptionSettings;
|
||||
}
|
||||
|
||||
function normalizeEditorPresetSnapshot(candidate: unknown): EditorPresetSnapshot {
|
||||
const normalizedPreferences = normalizeEditorPreferences(candidate);
|
||||
const raw =
|
||||
candidate && typeof candidate === "object"
|
||||
? (candidate as Partial<EditorPresetSnapshot>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
...normalizeEditorControls(normalizedPreferences, normalizedPreferences),
|
||||
autoCaptionSettings: normalizePresetAutoCaptionSettings(raw.autoCaptionSettings),
|
||||
whisperExecutablePath:
|
||||
normalizeNullablePath(raw.whisperExecutablePath) ?? normalizedPreferences.whisperExecutablePath,
|
||||
whisperModelPath:
|
||||
normalizeNullablePath(raw.whisperModelPath) ?? normalizedPreferences.whisperModelPath,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePresetName(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim().replace(/\s+/g, " ");
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizePresetTimestamp(value: unknown, fallback: string): string {
|
||||
if (typeof value !== "string") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
|
||||
}
|
||||
|
||||
function normalizeEditorPreset(candidate: unknown): EditorPreset | null {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = candidate as Partial<EditorPreset>;
|
||||
const name = normalizePresetName(raw.name);
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const id = typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : crypto.randomUUID();
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
createdAt: normalizePresetTimestamp(raw.createdAt, timestamp),
|
||||
updatedAt: normalizePresetTimestamp(raw.updatedAt, timestamp),
|
||||
snapshot: normalizeEditorPresetSnapshot(raw.snapshot),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEditorPresets(candidates: unknown): EditorPreset[] {
|
||||
if (!Array.isArray(candidates)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return candidates
|
||||
.map((item) => normalizeEditorPreset(item))
|
||||
.filter((preset): preset is EditorPreset => preset !== null)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
}
|
||||
|
||||
export function serializeEditorPresetSnapshot(snapshot: EditorPresetSnapshot): string {
|
||||
return JSON.stringify(normalizeEditorPresetSnapshot(snapshot));
|
||||
}
|
||||
|
||||
function normalizeEditorControls(
|
||||
raw: Partial<EditorPreferences>,
|
||||
fallback: EditorPreferences,
|
||||
@@ -300,3 +399,34 @@ export function saveEditorPreferences(preferences: Partial<EditorPreferences>):
|
||||
// Ignore storage failures so editor controls still work.
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEditorPresets(): EditorPreset[] {
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = globalThis.localStorage.getItem(EDITOR_PRESETS_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return normalizeEditorPresets(JSON.parse(stored));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveEditorPresets(presets: EditorPreset[]): boolean {
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = normalizeEditorPresets(presets);
|
||||
globalThis.localStorage.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(normalized));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1479,14 +1479,11 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
const activeClip = clipRegions.find(
|
||||
(clip) => startPos >= clip.startMs && startPos < clip.endMs,
|
||||
);
|
||||
if (!activeClip) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs);
|
||||
const nextRegion = sorted.find((region) => region.startMs > startPos);
|
||||
const gapToNextClipEdge = activeClip.endMs - startPos;
|
||||
const gapToNextRegion = nextRegion ? nextRegion.startMs - startPos : gapToNextClipEdge;
|
||||
const gapToNextClipEdge = activeClip ? activeClip.endMs - startPos : totalMs - startPos;
|
||||
const gapToNextRegion = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
|
||||
const availableDuration = Math.min(gapToNextClipEdge, gapToNextRegion);
|
||||
|
||||
const isOverlapping = sorted.some(
|
||||
@@ -1513,7 +1510,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
if (!canPlaceZoomAtMs(startPos)) {
|
||||
toast.error("Cannot place zoom here", {
|
||||
description:
|
||||
"Place zooms inside a kept clip and leave enough room before the clip ends.",
|
||||
"Zoom already exists here or there is not enough room before the next zoom or clip end.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CLICK_CLUSTER_MERGE_GAP_MS,
|
||||
CLICK_CLUSTER_PAD_MS,
|
||||
buildInteractionZoomSuggestions,
|
||||
} from "./zoomSuggestionUtils";
|
||||
import type { CursorTelemetryPoint } from "../types";
|
||||
|
||||
function makeClick(
|
||||
timeMs: number,
|
||||
cx = 0.5,
|
||||
cy = 0.5,
|
||||
interactionType: CursorTelemetryPoint["interactionType"] = "click",
|
||||
): CursorTelemetryPoint {
|
||||
return { timeMs, cx, cy, interactionType };
|
||||
}
|
||||
|
||||
function makeMove(timeMs: number, cx = 0.5, cy = 0.5): CursorTelemetryPoint {
|
||||
return { timeMs, cx, cy, interactionType: "move" };
|
||||
}
|
||||
|
||||
/** Wraps click samples with surrounding move events to mimic real mixed telemetry. */
|
||||
function withMoves(
|
||||
clicks: CursorTelemetryPoint[],
|
||||
totalMs: number,
|
||||
): CursorTelemetryPoint[] {
|
||||
return [
|
||||
makeMove(0),
|
||||
...clicks,
|
||||
makeMove(totalMs),
|
||||
];
|
||||
}
|
||||
|
||||
const TOTAL_MS = 30_000;
|
||||
|
||||
describe("buildInteractionZoomSuggestions (click-cluster logic)", () => {
|
||||
it("creates one zoom track for a single isolated click with 500ms padding", () => {
|
||||
const telemetry = withMoves([makeClick(5_000)], TOTAL_MS);
|
||||
|
||||
const result = buildInteractionZoomSuggestions({
|
||||
cursorTelemetry: telemetry,
|
||||
totalMs: TOTAL_MS,
|
||||
defaultDurationMs: 3_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.suggestions).toHaveLength(1);
|
||||
|
||||
const [s] = result.suggestions;
|
||||
expect(s.start).toBe(5_000 - CLICK_CLUSTER_PAD_MS);
|
||||
expect(s.end).toBe(5_000 + CLICK_CLUSTER_PAD_MS);
|
||||
});
|
||||
|
||||
it("accepts a single explicit click sample without needing surrounding moves", () => {
|
||||
const result = buildInteractionZoomSuggestions({
|
||||
cursorTelemetry: [makeClick(5_000)],
|
||||
totalMs: TOTAL_MS,
|
||||
defaultDurationMs: 3_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.suggestions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["right-click", "middle-click"] as const)(
|
||||
"accepts %s telemetry like a standard click",
|
||||
(interactionType) => {
|
||||
const result = buildInteractionZoomSuggestions({
|
||||
cursorTelemetry: withMoves([makeClick(5_000, 0.5, 0.5, interactionType)], TOTAL_MS),
|
||||
totalMs: TOTAL_MS,
|
||||
defaultDurationMs: 3_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.suggestions).toHaveLength(1);
|
||||
|
||||
const [suggestion] = result.suggestions;
|
||||
expect(suggestion.start).toBe(5_000 - CLICK_CLUSTER_PAD_MS);
|
||||
expect(suggestion.end).toBe(5_000 + CLICK_CLUSTER_PAD_MS);
|
||||
},
|
||||
);
|
||||
|
||||
it("merges two clicks within 2500ms into one zoom track", () => {
|
||||
const telemetry = withMoves(
|
||||
[makeClick(4_000), makeClick(4_000 + CLICK_CLUSTER_MERGE_GAP_MS - 1)],
|
||||
TOTAL_MS,
|
||||
);
|
||||
|
||||
const result = buildInteractionZoomSuggestions({
|
||||
cursorTelemetry: telemetry,
|
||||
totalMs: TOTAL_MS,
|
||||
defaultDurationMs: 3_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.suggestions).toHaveLength(1);
|
||||
|
||||
const [s] = result.suggestions;
|
||||
const lastClickMs = 4_000 + CLICK_CLUSTER_MERGE_GAP_MS - 1;
|
||||
expect(s.start).toBe(4_000 - CLICK_CLUSTER_PAD_MS);
|
||||
expect(s.end).toBe(lastClickMs + CLICK_CLUSTER_PAD_MS);
|
||||
});
|
||||
|
||||
it("splits two clicks more than 2500ms apart into separate zoom tracks", () => {
|
||||
const click1 = 3_000;
|
||||
const click2 = 3_000 + CLICK_CLUSTER_MERGE_GAP_MS + 1; // just outside the merge gap
|
||||
|
||||
const telemetry = withMoves([makeClick(click1), makeClick(click2)], TOTAL_MS);
|
||||
|
||||
const result = buildInteractionZoomSuggestions({
|
||||
cursorTelemetry: telemetry,
|
||||
totalMs: TOTAL_MS,
|
||||
defaultDurationMs: 3_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.suggestions).toHaveLength(2);
|
||||
|
||||
const [a, b] = result.suggestions;
|
||||
expect(a.start).toBe(click1 - CLICK_CLUSTER_PAD_MS);
|
||||
expect(a.end).toBe(click1 + CLICK_CLUSTER_PAD_MS);
|
||||
expect(b.start).toBe(click2 - CLICK_CLUSTER_PAD_MS);
|
||||
expect(b.end).toBe(click2 + CLICK_CLUSTER_PAD_MS);
|
||||
});
|
||||
|
||||
it("chains multiple clicks: 3 in a row within 2500ms each become one track", () => {
|
||||
// click at 0, 2000, 4000 — each gap is 2000ms < 2500ms
|
||||
const telemetry = withMoves([makeClick(0), makeClick(2_000), makeClick(4_000)], TOTAL_MS);
|
||||
|
||||
const result = buildInteractionZoomSuggestions({
|
||||
cursorTelemetry: telemetry,
|
||||
totalMs: TOTAL_MS,
|
||||
defaultDurationMs: 3_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.suggestions).toHaveLength(1);
|
||||
|
||||
const [s] = result.suggestions;
|
||||
expect(s.start).toBe(0); // clamped to 0 (would be -500)
|
||||
expect(s.end).toBe(4_000 + CLICK_CLUSTER_PAD_MS);
|
||||
});
|
||||
|
||||
it("returns no-interactions when there are no click telemetry points", () => {
|
||||
// Move events only — no clicks
|
||||
const telemetry: CursorTelemetryPoint[] = [
|
||||
{ timeMs: 0, cx: 0.5, cy: 0.5, interactionType: "move" },
|
||||
{ timeMs: 1_000, cx: 0.5, cy: 0.5, interactionType: "move" },
|
||||
{ timeMs: 2_000, cx: 0.6, cy: 0.6, interactionType: "move" },
|
||||
{ timeMs: TOTAL_MS, cx: 0.6, cy: 0.6, interactionType: "move" },
|
||||
];
|
||||
|
||||
const result = buildInteractionZoomSuggestions({
|
||||
cursorTelemetry: telemetry,
|
||||
totalMs: TOTAL_MS,
|
||||
defaultDurationMs: 3_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("no-interactions");
|
||||
expect(result.suggestions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores dwell-derived click-like heuristics when there are no explicit clicks", () => {
|
||||
const telemetry: CursorTelemetryPoint[] = [
|
||||
makeMove(0, 0.5, 0.5),
|
||||
makeMove(200, 0.5005, 0.5005),
|
||||
makeMove(400, 0.5008, 0.5008),
|
||||
makeMove(600, 0.501, 0.501),
|
||||
];
|
||||
|
||||
const result = buildInteractionZoomSuggestions({
|
||||
cursorTelemetry: telemetry,
|
||||
totalMs: TOTAL_MS,
|
||||
defaultDurationMs: 3_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("no-interactions");
|
||||
expect(result.suggestions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips clusters that overlap reserved spans", () => {
|
||||
const click = 5_000;
|
||||
|
||||
const result = buildInteractionZoomSuggestions({
|
||||
cursorTelemetry: withMoves([makeClick(click)], TOTAL_MS),
|
||||
totalMs: TOTAL_MS,
|
||||
defaultDurationMs: 3_000,
|
||||
reservedSpans: [{ start: 4_000, end: 6_000 }], // overlaps the cluster window
|
||||
});
|
||||
|
||||
expect(result.status).toBe("no-slots");
|
||||
expect(result.suggestions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("clamps start to 0 and end to totalMs at video boundaries", () => {
|
||||
const result = buildInteractionZoomSuggestions({
|
||||
cursorTelemetry: withMoves([makeClick(200)], 1_000),
|
||||
totalMs: 1_000,
|
||||
defaultDurationMs: 3_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
const [s] = result.suggestions;
|
||||
expect(s.start).toBeGreaterThanOrEqual(0);
|
||||
expect(s.end).toBeLessThanOrEqual(1_000);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export interface CursorInteractionCandidate extends ZoomDwellCandidate {
|
||||
| "dropdown-open"
|
||||
| "text-selection"
|
||||
| "text-field-click";
|
||||
source: "explicit" | "heuristic";
|
||||
}
|
||||
|
||||
export interface SuggestedZoomRegion {
|
||||
@@ -38,8 +39,22 @@ export interface InteractionZoomSuggestionResult {
|
||||
suggestions: SuggestedZoomRegion[];
|
||||
}
|
||||
|
||||
const DEFAULT_SUGGESTION_SPACING_MS = 1800;
|
||||
const DEFAULT_MERGE_NEARBY_GAP_MS = 1500;
|
||||
/** Max gap between consecutive clicks before they are split into separate zoom clusters. */
|
||||
export const CLICK_CLUSTER_MERGE_GAP_MS = 2500;
|
||||
/** Padding added before the first click and after the last click in a cluster. */
|
||||
export const CLICK_CLUSTER_PAD_MS = 500;
|
||||
const EXPLICIT_CLICK_TYPES = new Set<NonNullable<CursorTelemetryPoint["interactionType"]>>([
|
||||
"click",
|
||||
"double-click",
|
||||
"right-click",
|
||||
"middle-click",
|
||||
]);
|
||||
|
||||
function isExplicitClickType(
|
||||
interactionType: CursorTelemetryPoint["interactionType"],
|
||||
): interactionType is NonNullable<CursorTelemetryPoint["interactionType"]> {
|
||||
return typeof interactionType === "string" && EXPLICIT_CLICK_TYPES.has(interactionType);
|
||||
}
|
||||
|
||||
function normalizeTelemetrySample(
|
||||
sample: CursorTelemetryPoint,
|
||||
@@ -185,9 +200,7 @@ export function detectInteractionCandidates(
|
||||
samples: CursorTelemetryPoint[],
|
||||
): CursorInteractionCandidate[] {
|
||||
// --- Phase 1: Explicit interaction events (from uiohook telemetry) ---
|
||||
const clickEvents = samples.filter(
|
||||
(s) => s.interactionType && s.interactionType !== "move" && s.interactionType !== "mouseup",
|
||||
);
|
||||
const clickEvents = samples.filter((sample) => isExplicitClickType(sample.interactionType));
|
||||
|
||||
const explicitInteractionCandidates: CursorInteractionCandidate[] = [];
|
||||
|
||||
@@ -211,6 +224,7 @@ export function detectInteractionCandidates(
|
||||
focus: { cx: clickSample.cx, cy: clickSample.cy },
|
||||
strength: baseStrength,
|
||||
kind,
|
||||
source: "explicit",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -218,12 +232,12 @@ export function detectInteractionCandidates(
|
||||
const dwellCandidates = detectZoomDwellCandidates(samples).map<CursorInteractionCandidate>(
|
||||
(candidate) => {
|
||||
if (candidate.strength >= 1100) {
|
||||
return { ...candidate, kind: "text-focus-like" };
|
||||
return { ...candidate, kind: "text-focus-like", source: "heuristic" };
|
||||
}
|
||||
if (candidate.strength <= 800) {
|
||||
return { ...candidate, kind: "click-like" };
|
||||
return { ...candidate, kind: "click-like", source: "heuristic" };
|
||||
}
|
||||
return { ...candidate, kind: "dwell" };
|
||||
return { ...candidate, kind: "dwell", source: "heuristic" };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -247,6 +261,7 @@ export function detectInteractionCandidates(
|
||||
},
|
||||
strength: prev.strength + curr.strength + 500,
|
||||
kind: "double-click-like",
|
||||
source: "heuristic",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -254,6 +269,73 @@ export function detectInteractionCandidates(
|
||||
return [...explicitInteractionCandidates, ...dwellCandidates, ...doubleClickCandidates];
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups a sorted list of click timestamps into clusters where consecutive
|
||||
* clicks are no more than `mergeGapMs` apart. Returns an array of
|
||||
* `{ firstMs, lastMs, focus }` objects, one per cluster. The focus is taken
|
||||
* from the click with the highest interaction strength, falling back to the
|
||||
* centroid of all clicks in the cluster.
|
||||
*/
|
||||
function buildClickClusters(
|
||||
clicks: CursorInteractionCandidate[],
|
||||
mergeGapMs: number,
|
||||
): Array<{ firstMs: number; lastMs: number; focus: ZoomFocus }> {
|
||||
if (clicks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sorted = [...clicks].sort((a, b) => a.centerTimeMs - b.centerTimeMs);
|
||||
const clusters: Array<{ firstMs: number; lastMs: number; focus: ZoomFocus }> = [];
|
||||
|
||||
let clusterStart = sorted[0].centerTimeMs;
|
||||
let clusterEnd = sorted[0].centerTimeMs;
|
||||
let bestStrength = sorted[0].strength;
|
||||
let bestFocus = sorted[0].focus;
|
||||
let sumCx = sorted[0].focus.cx;
|
||||
let sumCy = sorted[0].focus.cy;
|
||||
let count = 1;
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const click = sorted[i];
|
||||
const gap = click.centerTimeMs - clusterEnd;
|
||||
|
||||
if (gap <= mergeGapMs) {
|
||||
// Extend current cluster
|
||||
clusterEnd = Math.max(clusterEnd, click.centerTimeMs);
|
||||
if (click.strength > bestStrength) {
|
||||
bestStrength = click.strength;
|
||||
bestFocus = click.focus;
|
||||
}
|
||||
sumCx += click.focus.cx;
|
||||
sumCy += click.focus.cy;
|
||||
count += 1;
|
||||
} else {
|
||||
// Flush current cluster and start a new one
|
||||
clusters.push({
|
||||
firstMs: clusterStart,
|
||||
lastMs: clusterEnd,
|
||||
focus: bestFocus ?? { cx: sumCx / count, cy: sumCy / count },
|
||||
});
|
||||
clusterStart = click.centerTimeMs;
|
||||
clusterEnd = click.centerTimeMs;
|
||||
bestStrength = click.strength;
|
||||
bestFocus = click.focus;
|
||||
sumCx = click.focus.cx;
|
||||
sumCy = click.focus.cy;
|
||||
count = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush last cluster
|
||||
clusters.push({
|
||||
firstMs: clusterStart,
|
||||
lastMs: clusterEnd,
|
||||
focus: bestFocus ?? { cx: sumCx / count, cy: sumCy / count },
|
||||
});
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
export function buildInteractionZoomSuggestions(params: {
|
||||
cursorTelemetry: CursorTelemetryPoint[];
|
||||
totalMs: number;
|
||||
@@ -261,82 +343,79 @@ export function buildInteractionZoomSuggestions(params: {
|
||||
reservedSpans?: Array<{ start: number; end: number }>;
|
||||
spacingMs?: number;
|
||||
mergeGapMs?: number;
|
||||
padMs?: number;
|
||||
}): InteractionZoomSuggestionResult {
|
||||
const {
|
||||
cursorTelemetry,
|
||||
totalMs,
|
||||
defaultDurationMs,
|
||||
reservedSpans = [],
|
||||
spacingMs = DEFAULT_SUGGESTION_SPACING_MS,
|
||||
mergeGapMs = DEFAULT_MERGE_NEARBY_GAP_MS,
|
||||
mergeGapMs = CLICK_CLUSTER_MERGE_GAP_MS,
|
||||
padMs = CLICK_CLUSTER_PAD_MS,
|
||||
} = params;
|
||||
|
||||
const defaultDuration = Math.min(defaultDurationMs, totalMs);
|
||||
if (defaultDuration <= 0) {
|
||||
if (totalMs <= 0) {
|
||||
return { status: "no-slots", suggestions: [] };
|
||||
}
|
||||
|
||||
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
|
||||
if (normalizedSamples.length < 2) {
|
||||
if (normalizedSamples.length === 0) {
|
||||
return { status: "no-telemetry", suggestions: [] };
|
||||
}
|
||||
|
||||
const interactionCandidates = detectInteractionCandidates(normalizedSamples);
|
||||
if (interactionCandidates.length === 0) {
|
||||
if (
|
||||
normalizedSamples.length === 1 &&
|
||||
!isExplicitClickType(normalizedSamples[0].interactionType)
|
||||
) {
|
||||
return { status: "no-telemetry", suggestions: [] };
|
||||
}
|
||||
|
||||
// Only use explicit click events (uiohook telemetry) – ignore dwell heuristics
|
||||
const clickCandidates = detectInteractionCandidates(normalizedSamples).filter(
|
||||
(candidate) => candidate.source === "explicit",
|
||||
);
|
||||
|
||||
if (clickCandidates.length === 0) {
|
||||
return { status: "no-interactions", suggestions: [] };
|
||||
}
|
||||
|
||||
const sortedCandidates = [...interactionCandidates].sort((a, b) => b.strength - a.strength);
|
||||
const acceptedCenters: number[] = [];
|
||||
const accepted: SuggestedZoomRegion[] = [];
|
||||
// Group nearby clicks into clusters, then derive zoom windows from those clusters
|
||||
const clusters = buildClickClusters(clickCandidates, mergeGapMs);
|
||||
|
||||
const reserved = [...reservedSpans].sort((a, b) => a.start - b.start);
|
||||
const suggestions: SuggestedZoomRegion[] = [];
|
||||
|
||||
sortedCandidates.forEach((candidate) => {
|
||||
const tooCloseToAccepted = acceptedCenters.some(
|
||||
(center) => Math.abs(center - candidate.centerTimeMs) < spacingMs,
|
||||
);
|
||||
for (const cluster of clusters) {
|
||||
const regionStart = Math.max(0, cluster.firstMs - padMs);
|
||||
const regionEnd = Math.min(totalMs, cluster.lastMs + padMs);
|
||||
|
||||
if (tooCloseToAccepted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const centeredStart = Math.round(candidate.centerTimeMs - defaultDuration / 2);
|
||||
const candidateStart = Math.max(0, Math.min(centeredStart, totalMs - defaultDuration));
|
||||
const candidateEnd = candidateStart + defaultDuration;
|
||||
const hasOverlap = reserved.some(
|
||||
(span) => candidateEnd > span.start && candidateStart < span.end,
|
||||
);
|
||||
|
||||
if (hasOverlap) {
|
||||
return;
|
||||
}
|
||||
|
||||
reserved.push({ start: candidateStart, end: candidateEnd });
|
||||
acceptedCenters.push(candidate.centerTimeMs);
|
||||
accepted.push({
|
||||
start: candidateStart,
|
||||
end: candidateEnd,
|
||||
focus: candidate.focus,
|
||||
});
|
||||
});
|
||||
|
||||
const sortedAccepted = [...accepted].sort((a, b) => a.start - b.start);
|
||||
const merged: SuggestedZoomRegion[] = [];
|
||||
for (const region of sortedAccepted) {
|
||||
const previous = merged[merged.length - 1];
|
||||
if (previous && region.start - previous.end <= mergeGapMs) {
|
||||
previous.end = Math.max(previous.end, region.end);
|
||||
if (regionEnd <= regionStart) {
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.push({ ...region });
|
||||
const hasOverlap = reserved.some(
|
||||
(span) => regionEnd > span.start && regionStart < span.end,
|
||||
);
|
||||
|
||||
if (hasOverlap) {
|
||||
continue;
|
||||
}
|
||||
|
||||
reserved.push({ start: regionStart, end: regionEnd });
|
||||
suggestions.push({
|
||||
start: regionStart,
|
||||
end: regionEnd,
|
||||
focus: cluster.focus,
|
||||
});
|
||||
}
|
||||
|
||||
if (merged.length === 0) {
|
||||
if (suggestions.length === 0) {
|
||||
return { status: "no-slots", suggestions: [] };
|
||||
}
|
||||
|
||||
return { status: "ok", suggestions: merged };
|
||||
// Sort chronologically
|
||||
suggestions.sort((a, b) => a.start - b.start);
|
||||
|
||||
return { status: "ok", suggestions };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Friendly reminder: Recordly is licensed under AGPL-3.0, author @webadderall, repo-> https://github.com/webadderall/Recordly
|
||||
// Friendly reminder: Recordly is licensed under AGPL-3.0, author @webadderall, repo-> https://github.com/webadderallorg/Recordly
|
||||
// Please use this code with the right attribution.
|
||||
|
||||
export interface SpringState {
|
||||
|
||||
+132
-31
@@ -1094,7 +1094,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
}
|
||||
|
||||
const wantsAudioCapture = microphoneEnabled || systemAudioEnabled;
|
||||
const browserCaptureSource = await resolveBrowserCaptureSource(selectedSource);
|
||||
|
||||
if (
|
||||
@@ -1128,11 +1127,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
cursor: "never" as const,
|
||||
};
|
||||
|
||||
if (wantsAudioCapture) {
|
||||
let screenMediaStream: MediaStream;
|
||||
const useLinuxPortal = selectedSource.id === "screen:linux-portal";
|
||||
const acquireLinuxPortalStream = (withAudio: boolean) =>
|
||||
mediaDevices.getDisplayMedia({
|
||||
|
||||
|
||||
const acquireLinuxPortalStream = async (withAudio: boolean): Promise<MediaStream> => {
|
||||
|
||||
try {
|
||||
return await mediaDevices.getDisplayMedia({
|
||||
audio: withAudio,
|
||||
video: {
|
||||
displaySurface: "monitor",
|
||||
@@ -1144,6 +1144,43 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
selfBrowserSurface: "exclude",
|
||||
surfaceSwitching: "exclude",
|
||||
});
|
||||
}
|
||||
|
||||
catch (err) {
|
||||
console.warn("Linux portal failed, falling back to desktop capture(no audio):", err);
|
||||
if (withAudio) {
|
||||
alert("System audio is not supported in fallback mode. Recording will continue without audio.");
|
||||
}
|
||||
|
||||
|
||||
const sources = await window.electronAPI.getSources({ types: ["screen"] });
|
||||
|
||||
if (!sources.length) {
|
||||
throw new Error("No screen sources available");
|
||||
}
|
||||
|
||||
const source = sources[0];
|
||||
console.log("Using fallback source:", source);
|
||||
|
||||
|
||||
|
||||
return await navigator.mediaDevices.getUserMedia({
|
||||
audio: false, //intentional
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: "desktop",
|
||||
chromeMediaSourceId: source.id,
|
||||
maxWidth: TARGET_WIDTH,
|
||||
maxHeight: TARGET_HEIGHT,
|
||||
maxFrameRate: TARGET_FRAME_RATE,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
};
|
||||
|
||||
let screenMediaStream: MediaStream;
|
||||
const useLinuxPortal = selectedSource.id === "screen:linux-portal";
|
||||
|
||||
if (systemAudioEnabled) {
|
||||
try {
|
||||
@@ -1248,26 +1285,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
} else if (micAudioTrack) {
|
||||
stream.current.addTrack(micAudioTrack);
|
||||
}
|
||||
} else {
|
||||
const mediaStream = await mediaDevices.getDisplayMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
displaySurface: selectedSource.id?.startsWith("window:")
|
||||
? "window"
|
||||
: "monitor",
|
||||
width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
|
||||
height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
|
||||
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
|
||||
cursor: "never",
|
||||
},
|
||||
selfBrowserSurface: "exclude",
|
||||
surfaceSwitching: "exclude",
|
||||
});
|
||||
|
||||
stream.current = mediaStream;
|
||||
videoTrack = mediaStream.getVideoTracks()[0];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if (!stream.current || !videoTrack) {
|
||||
throw new Error("Media stream is not available.");
|
||||
}
|
||||
@@ -1419,7 +1440,32 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
if (webcamRecorder.current?.state === "recording") {
|
||||
webcamRecorder.current.pause();
|
||||
}
|
||||
markRecordingPaused(Date.now());
|
||||
const boundaryMs = Date.now();
|
||||
try {
|
||||
await window.electronAPI.pauseCursorCapture(boundaryMs);
|
||||
} catch (error) {
|
||||
console.warn("Failed to pause cursor capture:", error);
|
||||
try {
|
||||
const rollbackResult =
|
||||
await window.electronAPI.resumeNativeScreenRecording();
|
||||
if (!rollbackResult.success) {
|
||||
console.warn(
|
||||
"Failed to roll back native pause after cursor pause failure:",
|
||||
rollbackResult.error ?? rollbackResult.message,
|
||||
);
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
console.warn(
|
||||
"Failed to roll back native pause after cursor pause failure:",
|
||||
rollbackError,
|
||||
);
|
||||
}
|
||||
if (webcamRecorder.current?.state === "paused") {
|
||||
webcamRecorder.current.resume();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markRecordingPaused(boundaryMs);
|
||||
setPaused(true);
|
||||
})();
|
||||
return;
|
||||
@@ -1429,8 +1475,23 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
if (webcamRecorder.current?.state === "recording") {
|
||||
webcamRecorder.current.pause();
|
||||
}
|
||||
markRecordingPaused(Date.now());
|
||||
setPaused(true);
|
||||
const boundaryMs = Date.now();
|
||||
void (async () => {
|
||||
try {
|
||||
await window.electronAPI.pauseCursorCapture(boundaryMs);
|
||||
} catch (error) {
|
||||
console.warn("Failed to pause cursor capture:", error);
|
||||
if (mediaRecorder.current?.state === "paused") {
|
||||
mediaRecorder.current.resume();
|
||||
}
|
||||
if (webcamRecorder.current?.state === "paused") {
|
||||
webcamRecorder.current.resume();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markRecordingPaused(boundaryMs);
|
||||
setPaused(true);
|
||||
})();
|
||||
}
|
||||
}, [markRecordingPaused, paused, recording]);
|
||||
|
||||
@@ -1450,7 +1511,32 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
if (webcamRecorder.current?.state === "paused") {
|
||||
webcamRecorder.current.resume();
|
||||
}
|
||||
markRecordingResumed(Date.now());
|
||||
const boundaryMs = Date.now();
|
||||
try {
|
||||
await window.electronAPI.resumeCursorCapture(boundaryMs);
|
||||
} catch (error) {
|
||||
console.warn("Failed to resume cursor capture:", error);
|
||||
try {
|
||||
const rollbackResult =
|
||||
await window.electronAPI.pauseNativeScreenRecording();
|
||||
if (!rollbackResult.success) {
|
||||
console.warn(
|
||||
"Failed to roll back native resume after cursor resume failure:",
|
||||
rollbackResult.error ?? rollbackResult.message,
|
||||
);
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
console.warn(
|
||||
"Failed to roll back native resume after cursor resume failure:",
|
||||
rollbackError,
|
||||
);
|
||||
}
|
||||
if (webcamRecorder.current?.state === "recording") {
|
||||
webcamRecorder.current.pause();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markRecordingResumed(boundaryMs);
|
||||
setPaused(false);
|
||||
})();
|
||||
return;
|
||||
@@ -1460,8 +1546,23 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
if (webcamRecorder.current?.state === "paused") {
|
||||
webcamRecorder.current.resume();
|
||||
}
|
||||
markRecordingResumed(Date.now());
|
||||
setPaused(false);
|
||||
const boundaryMs = Date.now();
|
||||
void (async () => {
|
||||
try {
|
||||
await window.electronAPI.resumeCursorCapture(boundaryMs);
|
||||
} catch (error) {
|
||||
console.warn("Failed to resume cursor capture:", error);
|
||||
if (mediaRecorder.current?.state === "recording") {
|
||||
mediaRecorder.current.pause();
|
||||
}
|
||||
if (webcamRecorder.current?.state === "recording") {
|
||||
webcamRecorder.current.pause();
|
||||
}
|
||||
return;
|
||||
}
|
||||
markRecordingResumed(boundaryMs);
|
||||
setPaused(false);
|
||||
})();
|
||||
}
|
||||
}, [markRecordingResumed, paused, recording]);
|
||||
|
||||
|
||||
@@ -120,6 +120,8 @@
|
||||
"generateFull": "Generate Captions",
|
||||
"regenerateFull": "Regenerate Captions",
|
||||
"clearFull": "Clear Captions",
|
||||
"editCurrent": "Edit current caption",
|
||||
"editSaved": "Caption updated",
|
||||
"fontSettings": "Font Settings",
|
||||
"defaultFont": "Default",
|
||||
"fontFamily": "Font",
|
||||
|
||||
@@ -120,6 +120,8 @@
|
||||
"generateFull": "Generar subtítulos",
|
||||
"regenerateFull": "Regenerar subtítulos",
|
||||
"clearFull": "Borrar subtítulos",
|
||||
"editCurrent": "Editar subtítulo actual",
|
||||
"editSaved": "Subtítulo actualizado",
|
||||
"fontSettings": "Tipografía",
|
||||
"defaultFont": "Predeterminado",
|
||||
"fontFamily": "Fuente",
|
||||
|
||||
@@ -120,6 +120,8 @@
|
||||
"generateFull": "Générer les sous-titres",
|
||||
"regenerateFull": "Régénérer les sous-titres",
|
||||
"clearFull": "Effacer les sous-titres",
|
||||
"editCurrent": "Modifier le sous-titre actuel",
|
||||
"editSaved": "Sous-titre mis à jour",
|
||||
"fontSettings": "Paramètres de police",
|
||||
"defaultFont": "Par défaut",
|
||||
"fontFamily": "Police",
|
||||
|
||||
@@ -120,6 +120,8 @@
|
||||
"generateFull": "자막 생성",
|
||||
"regenerateFull": "자막 다시 생성",
|
||||
"clearFull": "자막 지우기",
|
||||
"editCurrent": "현재 자막 편집",
|
||||
"editSaved": "자막이 업데이트되었습니다",
|
||||
"fontSettings": "글꼴 설정",
|
||||
"defaultFont": "기본값",
|
||||
"fontFamily": "글꼴",
|
||||
|
||||
@@ -120,6 +120,8 @@
|
||||
"generateFull": "Ondertiteling genereren",
|
||||
"regenerateFull": "Ondertiteling opnieuw genereren",
|
||||
"clearFull": "Ondertiteling wissen",
|
||||
"editCurrent": "Huidige ondertiteling bewerken",
|
||||
"editSaved": "Ondertiteling bijgewerkt",
|
||||
"fontSettings": "Lettertype-instellingen",
|
||||
"defaultFont": "Standaard",
|
||||
"fontFamily": "Lettertype",
|
||||
|
||||
@@ -120,6 +120,8 @@
|
||||
"generateFull": "Gerar legendas",
|
||||
"regenerateFull": "Gerar legendas novamente",
|
||||
"clearFull": "Limpar legendas",
|
||||
"editCurrent": "Editar legenda atual",
|
||||
"editSaved": "Legenda atualizada",
|
||||
"fontSettings": "Configurações da fonte",
|
||||
"defaultFont": "Padrão",
|
||||
"fontFamily": "Fonte",
|
||||
|
||||
@@ -120,6 +120,8 @@
|
||||
"generateFull": "生成字幕",
|
||||
"regenerateFull": "重新生成字幕",
|
||||
"clearFull": "清除字幕",
|
||||
"editCurrent": "编辑当前字幕",
|
||||
"editSaved": "字幕已更新",
|
||||
"fontSettings": "字体设置",
|
||||
"defaultFont": "默认",
|
||||
"fontFamily": "字体",
|
||||
|
||||
@@ -29,15 +29,16 @@
|
||||
"loopCursor": "游標循環",
|
||||
"cursorStyle": "游標樣式",
|
||||
"cursorStyleOptions": {
|
||||
"tahoe": "Tahoe",
|
||||
"dot": "圓點",
|
||||
"figma": "極簡",
|
||||
"mono": "反相",
|
||||
"lavender": "Lavender",
|
||||
"parched": "Parched",
|
||||
"chooper": "Chooper",
|
||||
"amongus": "Among Us",
|
||||
"turtle": "Turtle"
|
||||
"macos": "macOS",
|
||||
"tahoe": "Tahoe",
|
||||
"tahoe-inverted": "Tahoe Inverted",
|
||||
"dot": "圓點",
|
||||
"figma": "極簡",
|
||||
"lavender": "Lavender",
|
||||
"parched": "Parched",
|
||||
"chooper": "Chooper",
|
||||
"amongus": "Among Us",
|
||||
"turtle": "Turtle"
|
||||
},
|
||||
"backgroundBlur": "背景模糊",
|
||||
"zoomMotionBlur": "縮放動態模糊",
|
||||
@@ -62,11 +63,11 @@
|
||||
"connectedZoomDuration": "連接縮放時間",
|
||||
"connectedZoomEasing": "連接平移曲線",
|
||||
"zoomEasingOptions": {
|
||||
"recordly": "Recordly",
|
||||
"glide": "滑行",
|
||||
"smooth": "平滑",
|
||||
"snappy": "俐落",
|
||||
"linear": "線性"
|
||||
"recordly": "Recordly",
|
||||
"glide": "滑行",
|
||||
"smooth": "平滑",
|
||||
"snappy": "俐落",
|
||||
"linear": "線性"
|
||||
},
|
||||
"cursorSize": "游標大小",
|
||||
"cursorSmoothing": "游標平滑",
|
||||
@@ -91,9 +92,6 @@
|
||||
"radius": "半徑",
|
||||
"roundness": "圓角",
|
||||
"padding": "內距",
|
||||
"paddingAdvanced": "進階",
|
||||
"paddingAdvancedShow": "顯示進階內距控制",
|
||||
"paddingAdvancedHide": "隱藏進階內距控制",
|
||||
"paddingLinked": "連動(等距)",
|
||||
"paddingUnlinked": "不連動(不對稱)",
|
||||
"paddingTop": "上",
|
||||
@@ -122,6 +120,8 @@
|
||||
"generateFull": "產生字幕",
|
||||
"regenerateFull": "重新產生字幕",
|
||||
"clearFull": "清除字幕",
|
||||
"editCurrent": "編輯目前字幕",
|
||||
"editSaved": "字幕已更新",
|
||||
"fontSettings": "字型設定",
|
||||
"defaultFont": "預設",
|
||||
"fontFamily": "字型",
|
||||
@@ -163,10 +163,10 @@
|
||||
"mp4": "MP4",
|
||||
"gif": "GIF",
|
||||
"quality": {
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高",
|
||||
"original": "原始"
|
||||
"low": "低",
|
||||
"medium": "中",
|
||||
"high": "高",
|
||||
"original": "原始"
|
||||
},
|
||||
"fpsTitle": "FPS",
|
||||
"loop": "循環",
|
||||
@@ -180,4 +180,4 @@
|
||||
"reportBug": "回報錯誤",
|
||||
"starOnGithub": "在 GitHub 按讚"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user