Skip to content

Commit c033984

Browse files
authored
Merge branch 'main' into feat/countdown-before-record-start
2 parents 4a65ab8 + ae6b6ca commit c033984

8 files changed

Lines changed: 143 additions & 34 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dist-ssr
2727
*.sw?
2828
release/**
2929
*.kiro/
30+
.claude/
3031
# npx electron-builder --mac --win
3132

3233
# Playwright

src/components/launch/LaunchWindow.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ export function LaunchWindow() {
314314
};
315315

316316
return (
317-
<div className={`w-screen h-screen overflow-hidden bg-transparent ${styles.electronDrag}`}>
317+
<div className={`w-screen h-screen overflow-x-hidden bg-transparent ${styles.electronDrag}`}>
318318
{systemLocaleSuggestion && (
319319
<div
320320
className={`fixed top-8 left-1/2 z-30 w-[calc(100vw-1rem)] max-w-[520px] -translate-x-1/2 rounded-xl border border-white/15 bg-[rgba(20,20,28,0.95)] p-3 shadow-2xl backdrop-blur-xl text-white animate-in fade-in-0 zoom-in-95 duration-200 ${styles.electronNoDrag}`}
@@ -526,6 +526,7 @@ export function LaunchWindow() {
526526
onClick={async () => {
527527
await setWebcamEnabled(!webcamEnabled);
528528
}}
529+
disabled={recording}
529530
title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")}
530531
>
531532
{webcamEnabled

src/hooks/useScreenRecorder.ts

Lines changed: 96 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
112112
const restarting = useRef(false);
113113
const countdownRunId = useRef(0);
114114
const [countdownActive, setCountdownActive] = useState(false);
115+
const webcamReady = useRef(false);
116+
const webcamAcquireId = useRef(0);
115117

116118
const getRecordingDurationMs = useCallback(() => {
117119
const segmentDuration =
@@ -160,10 +162,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
160162
microphoneStream.current.getTracks().forEach((track) => track.stop());
161163
microphoneStream.current = null;
162164
}
163-
if (webcamStream.current) {
164-
webcamStream.current.getTracks().forEach((track) => track.stop());
165-
webcamStream.current = null;
166-
}
167165
if (mixingContext.current) {
168166
mixingContext.current.close().catch(() => {
169167
// Ignore close errors during recorder teardown.
@@ -196,6 +194,85 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
196194
[t],
197195
);
198196

197+
useEffect(() => {
198+
if (!webcamEnabled) return;
199+
200+
let cancelled = false;
201+
let acquiredStream: MediaStream | null = null;
202+
const thisAcquireId = ++webcamAcquireId.current;
203+
webcamReady.current = false;
204+
205+
const acquire = async () => {
206+
try {
207+
const stream = await navigator.mediaDevices.getUserMedia({
208+
audio: false,
209+
video: webcamDeviceId
210+
? {
211+
deviceId: { exact: webcamDeviceId },
212+
width: { ideal: WEBCAM_TARGET_WIDTH },
213+
height: { ideal: WEBCAM_TARGET_HEIGHT },
214+
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
215+
}
216+
: {
217+
width: { ideal: WEBCAM_TARGET_WIDTH },
218+
height: { ideal: WEBCAM_TARGET_HEIGHT },
219+
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
220+
},
221+
});
222+
223+
if (cancelled || thisAcquireId !== webcamAcquireId.current) {
224+
stream.getTracks().forEach((track) => {
225+
track.onended = null;
226+
track.stop();
227+
});
228+
return;
229+
}
230+
231+
acquiredStream = stream;
232+
stream.getVideoTracks().forEach((track) => {
233+
track.onended = () => {
234+
webcamStream.current = null;
235+
if (!restarting.current) {
236+
setWebcamEnabledState(false);
237+
toast.error(t("recording.cameraDisconnected"));
238+
}
239+
};
240+
});
241+
webcamStream.current = stream;
242+
webcamReady.current = true;
243+
} catch (cameraError) {
244+
if (!cancelled) {
245+
console.warn("Failed to get webcam access:", cameraError);
246+
setWebcamEnabledState(false);
247+
const isDeviceError =
248+
cameraError instanceof DOMException &&
249+
[
250+
"NotFoundError",
251+
"DevicesNotFoundError",
252+
"OverconstrainedError",
253+
"NotReadableError",
254+
].includes(cameraError.name);
255+
toast.error(t(isDeviceError ? "recording.cameraNotFound" : "recording.cameraBlocked"));
256+
webcamReady.current = true;
257+
}
258+
}
259+
};
260+
261+
void acquire();
262+
263+
return () => {
264+
cancelled = true;
265+
webcamReady.current = false;
266+
if (acquiredStream) {
267+
acquiredStream.getTracks().forEach((track) => {
268+
track.onended = null;
269+
track.stop();
270+
});
271+
webcamStream.current = null;
272+
}
273+
};
274+
}, [webcamEnabled, webcamDeviceId, t]);
275+
199276
const finalizeRecording = useCallback(
200277
(
201278
activeScreenRecorder: RecorderHandle,
@@ -568,30 +645,23 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
568645
}
569646

570647
if (webcamEnabled) {
571-
try {
572-
webcamStream.current = await navigator.mediaDevices.getUserMedia({
573-
audio: false,
574-
video: webcamDeviceId
575-
? {
576-
deviceId: { exact: webcamDeviceId },
577-
width: { ideal: WEBCAM_TARGET_WIDTH },
578-
height: { ideal: WEBCAM_TARGET_HEIGHT },
579-
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
580-
}
581-
: {
582-
width: { ideal: WEBCAM_TARGET_WIDTH },
583-
height: { ideal: WEBCAM_TARGET_HEIGHT },
584-
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
585-
},
648+
if (!webcamReady.current) {
649+
await new Promise<void>((resolve) => {
650+
const interval = setInterval(() => {
651+
if (webcamReady.current) {
652+
clearInterval(interval);
653+
resolve();
654+
}
655+
}, 50);
656+
setTimeout(() => {
657+
clearInterval(interval);
658+
resolve();
659+
}, 5000);
586660
});
587-
} catch (cameraError) {
588-
console.warn("Failed to get webcam access:", cameraError);
589-
if (webcamStream.current) {
590-
webcamStream.current.getTracks().forEach((track) => track.stop());
591-
webcamStream.current = null;
592-
}
661+
}
662+
if (!webcamStream.current) {
663+
webcamAcquireId.current++;
593664
setWebcamEnabledState(false);
594-
toast.error(t("recording.cameraDenied"));
595665
}
596666
}
597667

src/i18n/locales/en/editor.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
"systemAudioUnavailable": "System audio not available. Recording without system audio.",
3737
"microphoneDenied": "Microphone access denied. Recording will continue without audio.",
3838
"cameraDenied": "Camera access denied. Recording will continue without webcam.",
39+
"cameraDisconnected": "Webcam disconnected.",
40+
"cameraNotFound": "Camera not found.",
3941
"permissionDenied": "Recording permission denied. Please allow screen recording."
4042
}
4143
}

src/i18n/locales/es/editor.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
"systemAudioUnavailable": "Audio del sistema no disponible. Grabando sin audio del sistema.",
3131
"microphoneDenied": "Acceso al micrófono denegado. La grabación continuará sin audio.",
3232
"cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.",
33+
"cameraDisconnected": "Cámara web desconectada.",
34+
"cameraNotFound": "Cámara no encontrada.",
3335
"permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla."
3436
}
3537
}

src/i18n/locales/zh-CN/editor.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
"systemAudioUnavailable": "系统音频不可用。将在无系统音频的情况下录制。",
3737
"microphoneDenied": "麦克风权限被拒绝。录制将继续,但不包含音频。",
3838
"cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。",
39+
"cameraDisconnected": "摄像头已断开连接。",
40+
"cameraNotFound": "未找到摄像头。",
3941
"permissionDenied": "录屏权限被拒绝。请允许屏幕录制。"
4042
}
4143
}

src/lib/exporter/annotationRenderer.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,39 @@ import {
1010
let blurScratchCanvas: HTMLCanvasElement | null = null;
1111
let blurScratchCtx: CanvasRenderingContext2D | null = null;
1212

13+
// Matches a single code point whose script is Han (including non-BMP
14+
// Extension A-F), Hiragana, Katakana (including halfwidth forms), or
15+
// Hangul. Used to split CJK text at character boundaries during wrap,
16+
// since CJK scripts have no word-separating whitespace. Unicode script
17+
// property escapes require ES2018+; tsconfig target is ES2020.
18+
const CJK_CHAR = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
19+
20+
function tokenizeForWrap(line: string): string[] {
21+
// Split Latin text on whitespace (preserving the whitespace as its own token,
22+
// matching the original behavior), and split CJK runs into individual
23+
// characters so each one becomes a breakable unit. This mirrors the editor's
24+
// CSS `word-break: break-word` handling for CJK content.
25+
const tokens: string[] = [];
26+
let buffer = "";
27+
const chars = Array.from(line);
28+
const flushBuffer = () => {
29+
if (buffer) {
30+
tokens.push(...buffer.split(/(\s+)/).filter((s) => s.length > 0));
31+
buffer = "";
32+
}
33+
};
34+
for (const ch of chars) {
35+
if (CJK_CHAR.test(ch)) {
36+
flushBuffer();
37+
tokens.push(ch);
38+
} else {
39+
buffer += ch;
40+
}
41+
}
42+
flushBuffer();
43+
return tokens;
44+
}
45+
1346
// SVG path data for each arrow direction
1447
const ARROW_PATHS: Record<ArrowDirection, string[]> = {
1548
up: ["M 50 20 L 50 80", "M 50 20 L 35 35", "M 50 20 L 65 35"],
@@ -249,13 +282,13 @@ function renderText(
249282
lines.push("");
250283
continue;
251284
}
252-
const words = rawLine.split(/(\s+)/);
285+
const tokens = tokenizeForWrap(rawLine);
253286
let current = "";
254-
for (const word of words) {
255-
const test = current + word;
287+
for (const token of tokens) {
288+
const test = current + token;
256289
if (current && ctx.measureText(test).width > availableWidth) {
257290
lines.push(current);
258-
current = word.trimStart();
291+
current = token.trimStart();
259292
} else {
260293
current = test;
261294
}

src/lib/requestCameraAccess.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@ export async function requestCameraAccess(): Promise<CameraAccessResult> {
1717
if (window.electronAPI?.requestCameraAccess) {
1818
try {
1919
const electronResult = await window.electronAPI.requestCameraAccess();
20-
if (!electronResult.success || !electronResult.granted) {
21-
return electronResult;
22-
}
20+
return electronResult;
2321
} catch (error) {
2422
return {
2523
success: false,

0 commit comments

Comments
 (0)