diff --git a/.gitignore b/.gitignore index b2be27c..771c4bd 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ dist-ssr *.sw? release/** *.kiro/ +.claude/ # npx electron-builder --mac --win # Playwright diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 2914584..9b7d809 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -314,7 +314,7 @@ export function LaunchWindow() { }; return ( -
+
{systemLocaleSuggestion && (
{ await setWebcamEnabled(!webcamEnabled); }} + disabled={recording} title={webcamEnabled ? t("webcam.disableWebcam") : t("webcam.enableWebcam")} > {webcamEnabled diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index ca14dfe..913386c 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -112,6 +112,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const restarting = useRef(false); const countdownRunId = useRef(0); const [countdownActive, setCountdownActive] = useState(false); + const webcamReady = useRef(false); + const webcamAcquireId = useRef(0); const getRecordingDurationMs = useCallback(() => { const segmentDuration = @@ -160,10 +162,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { microphoneStream.current.getTracks().forEach((track) => track.stop()); microphoneStream.current = null; } - if (webcamStream.current) { - webcamStream.current.getTracks().forEach((track) => track.stop()); - webcamStream.current = null; - } if (mixingContext.current) { mixingContext.current.close().catch(() => { // Ignore close errors during recorder teardown. @@ -196,6 +194,85 @@ export function useScreenRecorder(): UseScreenRecorderReturn { [t], ); + useEffect(() => { + if (!webcamEnabled) return; + + let cancelled = false; + let acquiredStream: MediaStream | null = null; + const thisAcquireId = ++webcamAcquireId.current; + webcamReady.current = false; + + const acquire = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: webcamDeviceId + ? { + deviceId: { exact: webcamDeviceId }, + width: { ideal: WEBCAM_TARGET_WIDTH }, + height: { ideal: WEBCAM_TARGET_HEIGHT }, + frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE }, + } + : { + width: { ideal: WEBCAM_TARGET_WIDTH }, + height: { ideal: WEBCAM_TARGET_HEIGHT }, + frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE }, + }, + }); + + if (cancelled || thisAcquireId !== webcamAcquireId.current) { + stream.getTracks().forEach((track) => { + track.onended = null; + track.stop(); + }); + return; + } + + acquiredStream = stream; + stream.getVideoTracks().forEach((track) => { + track.onended = () => { + webcamStream.current = null; + if (!restarting.current) { + setWebcamEnabledState(false); + toast.error(t("recording.cameraDisconnected")); + } + }; + }); + webcamStream.current = stream; + webcamReady.current = true; + } catch (cameraError) { + if (!cancelled) { + console.warn("Failed to get webcam access:", cameraError); + setWebcamEnabledState(false); + const isDeviceError = + cameraError instanceof DOMException && + [ + "NotFoundError", + "DevicesNotFoundError", + "OverconstrainedError", + "NotReadableError", + ].includes(cameraError.name); + toast.error(t(isDeviceError ? "recording.cameraNotFound" : "recording.cameraBlocked")); + webcamReady.current = true; + } + } + }; + + void acquire(); + + return () => { + cancelled = true; + webcamReady.current = false; + if (acquiredStream) { + acquiredStream.getTracks().forEach((track) => { + track.onended = null; + track.stop(); + }); + webcamStream.current = null; + } + }; + }, [webcamEnabled, webcamDeviceId, t]); + const finalizeRecording = useCallback( ( activeScreenRecorder: RecorderHandle, @@ -568,30 +645,23 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } if (webcamEnabled) { - try { - webcamStream.current = await navigator.mediaDevices.getUserMedia({ - audio: false, - video: webcamDeviceId - ? { - deviceId: { exact: webcamDeviceId }, - width: { ideal: WEBCAM_TARGET_WIDTH }, - height: { ideal: WEBCAM_TARGET_HEIGHT }, - frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE }, - } - : { - width: { ideal: WEBCAM_TARGET_WIDTH }, - height: { ideal: WEBCAM_TARGET_HEIGHT }, - frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE }, - }, + if (!webcamReady.current) { + await new Promise((resolve) => { + const interval = setInterval(() => { + if (webcamReady.current) { + clearInterval(interval); + resolve(); + } + }, 50); + setTimeout(() => { + clearInterval(interval); + resolve(); + }, 5000); }); - } catch (cameraError) { - console.warn("Failed to get webcam access:", cameraError); - if (webcamStream.current) { - webcamStream.current.getTracks().forEach((track) => track.stop()); - webcamStream.current = null; - } + } + if (!webcamStream.current) { + webcamAcquireId.current++; setWebcamEnabledState(false); - toast.error(t("recording.cameraDenied")); } } diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index ea2ceaa..a171b16 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -36,6 +36,8 @@ "systemAudioUnavailable": "System audio not available. Recording without system audio.", "microphoneDenied": "Microphone access denied. Recording will continue without audio.", "cameraDenied": "Camera access denied. Recording will continue without webcam.", + "cameraDisconnected": "Webcam disconnected.", + "cameraNotFound": "Camera not found.", "permissionDenied": "Recording permission denied. Please allow screen recording." } } diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 99adc78..7956b75 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -30,6 +30,8 @@ "systemAudioUnavailable": "Audio del sistema no disponible. Grabando sin audio del sistema.", "microphoneDenied": "Acceso al micrófono denegado. La grabación continuará sin audio.", "cameraDenied": "Acceso a la cámara denegado. La grabación continuará sin cámara web.", + "cameraDisconnected": "Cámara web desconectada.", + "cameraNotFound": "Cámara no encontrada.", "permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla." } } diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 44abab9..1980354 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -36,6 +36,8 @@ "systemAudioUnavailable": "系统音频不可用。将在无系统音频的情况下录制。", "microphoneDenied": "麦克风权限被拒绝。录制将继续,但不包含音频。", "cameraDenied": "摄像头权限被拒绝。录制将继续,但不包含摄像头画面。", + "cameraDisconnected": "摄像头已断开连接。", + "cameraNotFound": "未找到摄像头。", "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。" } } diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index b0c4948..c0d5657 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -10,6 +10,39 @@ import { let blurScratchCanvas: HTMLCanvasElement | null = null; let blurScratchCtx: CanvasRenderingContext2D | null = null; +// Matches a single code point whose script is Han (including non-BMP +// Extension A-F), Hiragana, Katakana (including halfwidth forms), or +// Hangul. Used to split CJK text at character boundaries during wrap, +// since CJK scripts have no word-separating whitespace. Unicode script +// property escapes require ES2018+; tsconfig target is ES2020. +const CJK_CHAR = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; + +function tokenizeForWrap(line: string): string[] { + // Split Latin text on whitespace (preserving the whitespace as its own token, + // matching the original behavior), and split CJK runs into individual + // characters so each one becomes a breakable unit. This mirrors the editor's + // CSS `word-break: break-word` handling for CJK content. + const tokens: string[] = []; + let buffer = ""; + const chars = Array.from(line); + const flushBuffer = () => { + if (buffer) { + tokens.push(...buffer.split(/(\s+)/).filter((s) => s.length > 0)); + buffer = ""; + } + }; + for (const ch of chars) { + if (CJK_CHAR.test(ch)) { + flushBuffer(); + tokens.push(ch); + } else { + buffer += ch; + } + } + flushBuffer(); + return tokens; +} + // SVG path data for each arrow direction const ARROW_PATHS: Record = { 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( lines.push(""); continue; } - const words = rawLine.split(/(\s+)/); + const tokens = tokenizeForWrap(rawLine); let current = ""; - for (const word of words) { - const test = current + word; + for (const token of tokens) { + const test = current + token; if (current && ctx.measureText(test).width > availableWidth) { lines.push(current); - current = word.trimStart(); + current = token.trimStart(); } else { current = test; } diff --git a/src/lib/requestCameraAccess.ts b/src/lib/requestCameraAccess.ts index 2494224..cfcd4d1 100644 --- a/src/lib/requestCameraAccess.ts +++ b/src/lib/requestCameraAccess.ts @@ -17,9 +17,7 @@ export async function requestCameraAccess(): Promise { if (window.electronAPI?.requestCameraAccess) { try { const electronResult = await window.electronAPI.requestCameraAccess(); - if (!electronResult.success || !electronResult.granted) { - return electronResult; - } + return electronResult; } catch (error) { return { success: false,