diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 790884e..6a37680 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,7 +31,10 @@ jobs: uses: actions/upload-artifact@v4 with: name: windows-installer - path: release/**/*.exe + path: | + release/**/*.exe + release/**/*.exe.blockmap + release/**/latest.yml retention-days: 30 build-macos: @@ -65,7 +68,11 @@ jobs: uses: actions/upload-artifact@v4 with: name: macos-installer - path: release/**/*.dmg + path: | + release/**/*.dmg + release/**/*.dmg.blockmap + release/**/*.zip + release/**/latest-mac.yml retention-days: 30 build-linux: @@ -94,6 +101,8 @@ jobs: uses: actions/upload-artifact@v4 with: name: linux-installer - path: release/**/*.AppImage + path: | + release/**/*.AppImage + release/**/latest-linux.yml retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5dfa80b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,144 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build-windows: + runs-on: windows-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + run: npm ci + + - name: Install app dependencies + run: npx electron-builder install-app-deps + + - name: Build Windows app + run: npm run build:win + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + name: windows-build + path: | + release/**/*.exe + release/**/*.exe.blockmap + release/**/latest.yml + retention-days: 1 + + build-macos: + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: npm ci + + - name: Install app dependencies + run: npx electron-builder install-app-deps + + - name: Build macOS app + run: npm run build:mac + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload macOS artifact + uses: actions/upload-artifact@v4 + with: + name: macos-build + path: | + release/**/*.dmg + release/**/*.dmg.blockmap + release/**/*.zip + release/**/latest-mac.yml + retention-days: 1 + + build-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + run: npm ci + + - name: Install app dependencies + run: npx electron-builder install-app-deps + + - name: Build Linux app + run: npm run build:linux + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Linux artifact + uses: actions/upload-artifact@v4 + with: + name: linux-build + path: | + release/**/*.AppImage + release/**/latest-linux.yml + retention-days: 1 + + release: + needs: [build-windows, build-macos, build-linux] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Display structure of downloaded files + run: | + if [ -d artifacts ]; then + ls -R artifacts + else + echo "No artifacts directory found" + fi + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/**/*.exe + artifacts/**/*.dmg + artifacts/**/*.AppImage + artifacts/**/*.zip + draft: false + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2bbbe2..2d3e673 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,14 @@ Thank you for considering contributing to this project! By contributing, you hel 5. **Test Your Changes** - Test your changes thoroughly to ensure they work as expected and do not break existing functionality. + ### FFmpeg-based recording notes + + The screen recording workflow is migrating away from Chromium's `MediaRecorder` to an external `ffmpeg` process (and `ffprobe` for post-recording validation). + + - Ensure `ffmpeg` and `ffprobe` are available on your PATH, or set `OPENSCREEN_FFMPEG_PATH` / `OPENSCREEN_FFPROBE_PATH`. + - Run a quick sanity check: `npm run verify:ffmpeg` + - Current platform support: Windows + macOS are supported, Linux falls back to the legacy `MediaRecorder` workflow for now. + 6. **Commit Your Changes** - Commit your changes with a clear and concise commit message: ```bash @@ -54,4 +62,4 @@ If you encounter a bug or have a feature request, please open an issue in the [I By contributing to this project, you agree that your contributions will be licensed under the [MIT License](./LICENSE). -Thank you for your contributions! \ No newline at end of file +Thank you for your contributions! diff --git a/README.md b/README.md index 1d754f7..323fd35 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ #

OpenScreen

+

+ English | 简体中文 +

+

OpenScreen is your free, open-source alternative to Screen Studio (sort of).

@@ -46,7 +50,7 @@ OpenScreen is 100% free for personal and commercial use. Use it, modify it, dist ## Installation -Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page. +Download the latest installer for your platform from the [GitHub Releases](https://github.com/fengjinyi98/openscreen/releases) page. ### macOS @@ -82,12 +86,20 @@ You may need to grant screen recording permissions depending on your desktop env _I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_ -## Contributing - -Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute. - - -## License +## Contributing + +Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute. + +### FFmpeg recording (dev note) + +The recording workflow is migrating from Chromium's `MediaRecorder` to an external `ffmpeg` process (and uses `ffprobe` for post-recording validation). + +- Ensure `ffmpeg` and `ffprobe` are available on your PATH, or set `OPENSCREEN_FFMPEG_PATH` / `OPENSCREEN_FFPROBE_PATH`. +- Run a quick sanity check: `npm run verify:ffmpeg` +- Current platform strategy: Windows + macOS use FFmpeg, Linux falls back to the legacy `MediaRecorder` workflow for now. + + +## License This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use. diff --git a/README_zh-CN.md b/README_zh-CN.md new file mode 100644 index 0000000..e657c23 --- /dev/null +++ b/README_zh-CN.md @@ -0,0 +1,102 @@ +

+ OpenScreen Logo +
+
+ + Ask DeepWiki + +

+ +#

OpenScreen

+ +

+ English | 简体中文 +

+ +

OpenScreen 是 Screen Studio 的免费开源替代品。

+ +如果你不想为 Screen Studio 每月支付 29 美元,但需要一个简单的工具来制作精美的产品演示和教程视频,这款免费应用就是为你准备的。OpenScreen 并未涵盖 Screen Studio 的所有功能,但已经能够满足基本需求! + +Screen Studio 是一款出色的产品,这绝对不是一个 1:1 的克隆。OpenScreen 是一个更简洁的版本,为那些想要掌控一切且不想付费的用户提供基础功能。如果你需要所有高级功能,最好还是支持 Screen Studio(他们确实做得很棒)。但如果你只是想要一个免费(没有任何附加条件)且开源的工具,这个项目完全可以胜任! + +OpenScreen 对个人和商业用途都是 100% 免费的。你可以使用、修改、分发它。(如果你愿意的话,请给个 star 支持一下!😁) + +**⚠️ 免责声明:这是一个测试版本,可能存在一些 bug(但希望你能有良好的使用体验!)** + +

+

+ OpenScreen 应用预览 + OpenScreen 应用预览 2 + OpenScreen 应用预览 3 + OpenScreen 应用预览 4 + +

+

+ +## 核心功能 +- 录制整个屏幕或特定应用窗口 +- 添加手动缩放(可自定义缩放深度) +- 自由定制缩放的持续时间和位置 +- 裁剪视频录制以隐藏部分内容 +- 选择壁纸、纯色、渐变或自定义图片作为背景 +- 运动模糊效果,使平移和缩放更流畅 +- 添加标注(文字、箭头、图片) +- 剪辑片段 +- 以不同的宽高比和分辨率导出 + +## 安装 + +从 [GitHub Releases](https://github.com/fengjinyi98/openscreen/releases) 页面下载适合你平台的最新安装包。 + +### macOS + +如果你遇到 macOS Gatekeeper 阻止应用运行的问题(因为应用没有开发者证书),可以在安装后运行以下终端命令来绕过: + +```bash +xattr -rd com.apple.quarantine /Applications/Openscreen.app +``` + +运行此命令后,请前往 **系统偏好设置 > 安全性与隐私** 授予"屏幕录制"和"辅助功能"权限。授权完成后即可启动应用。 + +### Linux + +从 releases 页面下载 `.AppImage` 文件。赋予执行权限后运行: + +```bash +chmod +x Openscreen-Linux-*.AppImage +./Openscreen-Linux-*.AppImage +``` + +根据你的桌面环境,可能需要授予屏幕录制权限。 + +### Windows + +从 releases 页面下载 `.exe` 安装程序,双击运行即可安装。 + +## 技术栈 +- Electron +- React +- TypeScript +- Vite +- PixiJS +- dnd-timeline + +--- + +_如果有任何问题,请提交 issue 🙏_ + +## 贡献 + +欢迎贡献!如果你想帮忙或查看当前正在进行的工作,请查看 open issues 和 [项目路线图](https://github.com/users/siddharthvaddem/projects/3) 来了解项目的当前方向并找到贡献的方式。 + +### 录制链路(FFmpeg)开发提示 + +录制工作流正在从 Chromium 的 `MediaRecorder` 迁移到外部 `ffmpeg` 进程(并使用 `ffprobe` 在录制结束后做自动校验)。 + +- 请确保 `ffmpeg`/`ffprobe` 已加入 PATH,或设置环境变量:`OPENSCREEN_FFMPEG_PATH` / `OPENSCREEN_FFPROBE_PATH` +- 可运行冒烟自检:`npm run verify:ffmpeg` +- 当前平台策略:Windows / macOS 优先走 FFmpeg,Linux 暂时回退到旧的 `MediaRecorder` 工作流 + +## 许可证 + +本项目采用 [MIT 许可证](./LICENSE)。使用本软件即表示你同意作者不对因使用本软件而产生的任何问题、损害或索赔承担责任。 diff --git a/dist-electron/main.js b/dist-electron/main.js index e303928..0274da6 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -1,7 +1,12 @@ -import { ipcMain, screen, BrowserWindow, desktopCapturer, shell, app, dialog, nativeImage, Tray, Menu } from "electron"; +var __defProp = Object.defineProperty; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); +import { ipcMain, screen, BrowserWindow, app, desktopCapturer, shell, dialog, nativeImage, Tray, Menu } from "electron"; import { fileURLToPath } from "node:url"; import path from "node:path"; -import fs from "node:fs/promises"; +import fs, { access } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { constants } from "node:fs"; const __dirname$1 = path.dirname(fileURLToPath(import.meta.url)); const APP_ROOT = path.join(__dirname$1, ".."); const VITE_DEV_SERVER_URL$1 = process.env["VITE_DEV_SERVER_URL"]; @@ -60,13 +65,16 @@ function createHudOverlayWindow() { return win; } function createEditorWindow() { + const isMac = process.platform === "darwin"; const win = new BrowserWindow({ width: 1200, height: 800, minWidth: 800, minHeight: 600, - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 12, y: 12 }, + ...isMac && { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 12, y: 12 } + }, transparent: false, resizable: true, alwaysOnTop: false, @@ -123,8 +131,506 @@ function createSourceSelectorWindow() { } return win; } +function interpolate(template, values) { + if (!values) return template; + return template.replace(/{{\s*([a-zA-Z0-9_]+)\s*}}/g, (_match, key) => { + const value = values[key]; + return value === void 0 || value === null ? "" : String(value); + }); +} +function normalizeLanguage(value) { + if (!value) return null; + const lower = value.toLowerCase(); + if (lower === "zh" || lower === "zh-cn" || lower.startsWith("zh-")) return "zh-CN"; + if (lower === "en" || lower.startsWith("en-")) return "en"; + return null; +} +function getAppLanguage() { + var _a, _b; + try { + return normalizeLanguage((_b = (_a = app).getLocale) == null ? void 0 : _b.call(_a)) ?? "en"; + } catch { + return "en"; + } +} +const zhCN = { + "Recording: {{source}}": "正在录制:{{source}}", + "Stop Recording": "停止录制", + "Open": "打开", + "Quit": "退出", + "Save Exported Video": "保存导出的视频", + "MP4 Video": "MP4 视频", + "Export cancelled": "已取消导出", + "Video exported successfully": "视频导出成功", + "Failed to save exported video": "保存导出的视频失败", + "Select Video File": "选择视频文件", + "Video Files": "视频文件", + "All Files": "所有文件", + "Failed to open file picker": "打开文件选择器失败", + "Video stored successfully": "视频保存成功", + "Failed to store video": "保存视频失败", + "No recorded video found": "未找到录制视频", + "Failed to get video path": "获取视频路径失败", + "Please select a source to record": "请先选择要录制的来源", + "Invalid recording source": "录制来源无效", + "FFmpeg recording currently supports only screen sources.": "FFmpeg 录制目前仅支持屏幕来源。", + "FFmpeg recording is not supported on this platform yet.": "FFmpeg 录制暂不支持当前平台。", + "Failed to start recording": "开始录制失败", + "Failed to stop recording": "停止录制失败", + "Screen": "屏幕" +}; +function tMain(key, values) { + const language = getAppLanguage(); + const template = language === "zh-CN" ? zhCN[key] ?? key : key; + return interpolate(template, values); +} +function normalizeFps(fps) { + if (!Number.isFinite(fps) || fps <= 0) return 60; + return Math.round(fps); +} +function ensureEven(value) { + if (!Number.isFinite(value)) return 0; + const rounded = Math.round(value); + return rounded - rounded % 2; +} +async function fileExists(filePath) { + try { + await access(filePath, constants.F_OK); + return true; + } catch { + return false; + } +} +async function resolveBinary(explicitPath, fallback) { + if (explicitPath && await fileExists(explicitPath)) return explicitPath; + return fallback; +} +async function probeEncoder(ffmpegPath, encoder, timeoutMs) { + return await new Promise((resolve) => { + const args = [ + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "testsrc2=size=128x128:rate=30", + "-t", + "0.2", + "-c:v", + encoder, + "-f", + "null", + "-" + ]; + const proc = spawn(ffmpegPath, args, { windowsHide: true }); + let stderr = ""; + const timer = setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch { + } + resolve({ ok: false, error: "encoder probe timeout" }); + }, timeoutMs); + proc.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + proc.on("error", (err) => { + clearTimeout(timer); + resolve({ ok: false, error: String(err) }); + }); + proc.on("close", (code) => { + clearTimeout(timer); + resolve({ ok: code === 0, error: code === 0 ? void 0 : stderr.trim() || `exit ${code ?? "unknown"}` }); + }); + }); +} +async function selectEncoder(ffmpegPath) { + const platform = process.platform; + const candidates = platform === "win32" ? ["h264_nvenc", "h264_amf", "h264_qsv", "libx264"] : platform === "darwin" ? ["h264_videotoolbox", "libx264"] : ["libx264"]; + for (const encoder of candidates) { + const { ok } = await probeEncoder(ffmpegPath, encoder, 4e3); + if (ok) return encoder; + } + return "libx264"; +} +function buildVideoFilters(fps) { + const forcedFps = normalizeFps(fps); + return [ + `fps=${forcedFps}`, + "format=yuv420p", + // 保证宽高为偶数,避免编码器因奇数尺寸失败 + "scale=trunc(iw/2)*2:trunc(ih/2)*2" + ]; +} +function buildEncoderArgs(encoder) { + switch (encoder) { + case "h264_nvenc": + return [ + "-c:v", + "h264_nvenc", + "-preset", + "p7", + "-rc", + "vbr", + "-cq", + "18", + "-b:v", + "0" + ]; + case "h264_videotoolbox": + return [ + "-c:v", + "h264_videotoolbox", + // 以较高码率优先保证清晰度(接近无损);具体码率仍会因设备能力调整 + "-b:v", + "60000k" + ]; + case "h264_amf": + return [ + "-c:v", + "h264_amf", + "-quality", + "quality", + "-rc", + "cqp", + "-qp_i", + "18", + "-qp_p", + "18" + ]; + case "h264_qsv": + return [ + "-c:v", + "h264_qsv", + "-global_quality", + "18" + ]; + case "libx264": + default: + return [ + "-c:v", + "libx264", + // 作为软件回退,优先保证能跑满帧率,文件体积会更大 + "-preset", + "ultrafast", + "-crf", + "18", + "-tune", + "stillimage" + ]; + } +} +function buildCaptureArgs(target, fps) { + var _a; + const forcedFps = normalizeFps(fps); + const platform = process.platform; + if (platform === "win32") { + if (target.kind === "screen") { + const width = ensureEven(target.boundsPx.width); + const height = ensureEven(target.boundsPx.height); + const x = Math.round(target.boundsPx.x); + const y = Math.round(target.boundsPx.y); + return [ + "-f", + "gdigrab", + "-framerate", + String(forcedFps), + "-draw_mouse", + "1", + "-offset_x", + String(x), + "-offset_y", + String(y), + "-video_size", + `${width}x${height}`, + "-i", + "desktop" + ]; + } + if (target.kind === "window") { + const title = (_a = target.title) == null ? void 0 : _a.trim(); + if (!title) { + throw new Error("Invalid window title."); + } + return [ + "-f", + "gdigrab", + "-framerate", + String(forcedFps), + "-draw_mouse", + "1", + "-i", + `title=${title}` + ]; + } + } + if (platform === "darwin") { + if (target.kind === "screen") { + const index = typeof target.displayIndex === "number" ? target.displayIndex : 0; + const device = `Capture screen ${index}`; + return [ + "-f", + "avfoundation", + "-framerate", + String(forcedFps), + "-i", + `${device}:none` + ]; + } + } + throw new Error("Unsupported recording target for current platform."); +} +async function runFfprobe(ffprobePath, filePath, timeoutMs) { + return await new Promise((resolve) => { + const args = [ + "-v", + "error", + "-print_format", + "json", + "-show_format", + "-show_streams", + filePath + ]; + const proc = spawn(ffprobePath, args, { windowsHide: true }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch { + } + resolve(null); + }, timeoutMs); + proc.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + proc.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + proc.on("error", () => { + clearTimeout(timer); + resolve(null); + }); + proc.on("close", (code) => { + clearTimeout(timer); + if (code !== 0) { + console.warn("ffprobe failed:", stderr.trim()); + resolve(null); + return; + } + try { + const parsed = JSON.parse(stdout); + const root = typeof parsed === "object" && parsed !== null ? parsed : {}; + const format = typeof root.format === "object" && root.format !== null ? root.format : {}; + const streams = Array.isArray(root.streams) ? root.streams : []; + const rawVideoStream = streams.find( + (value) => typeof value === "object" && value !== null && value.codec_type === "video" + ) ?? {}; + const videoStream = rawVideoStream; + const parseRate = (value) => { + if (!value || typeof value !== "string") return void 0; + const [numStr, denStr] = value.split("/"); + const num = Number(numStr); + const den = Number(denStr); + if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return void 0; + return num / den; + }; + const sizeBytes = Number(format.size); + const durationSeconds = Number(format.duration); + const result = { + formatName: typeof format.format_name === "string" ? format.format_name : void 0, + durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : void 0, + sizeBytes: Number.isFinite(sizeBytes) ? sizeBytes : void 0, + video: { + codec: typeof videoStream.codec_name === "string" ? videoStream.codec_name : void 0, + width: Number.isFinite(Number(videoStream.width)) ? Number(videoStream.width) : void 0, + height: Number.isFinite(Number(videoStream.height)) ? Number(videoStream.height) : void 0, + avgFrameRate: parseRate( + typeof videoStream.avg_frame_rate === "string" ? videoStream.avg_frame_rate : void 0 + ), + rFrameRate: parseRate( + typeof videoStream.r_frame_rate === "string" ? videoStream.r_frame_rate : void 0 + ), + bitRate: Number.isFinite(Number(videoStream.bit_rate)) ? Number(videoStream.bit_rate) : void 0, + pixFmt: typeof videoStream.pix_fmt === "string" ? videoStream.pix_fmt : void 0 + } + }; + resolve(result); + } catch (error) { + console.warn("Failed to parse ffprobe output:", error); + resolve(null); + } + }); + }); +} +class FfmpegRecorder { + constructor(config) { + __publicField(this, "config"); + __publicField(this, "session", null); + __publicField(this, "starting", false); + __publicField(this, "stopping", false); + this.config = config; + } + isRecording() { + return this.session !== null; + } + async start(options) { + if (this.session || this.starting || this.stopping) { + return { success: false, backend: "ffmpeg", message: "Recording already in progress." }; + } + this.starting = true; + try { + const ffmpegPath = await resolveBinary( + this.config.ffmpegPath || process.env.OPENSCREEN_FFMPEG_PATH, + "ffmpeg" + ); + const ffprobePath = await resolveBinary( + this.config.ffprobePath || process.env.OPENSCREEN_FFPROBE_PATH, + "ffprobe" + ); + if (!ffmpegPath) { + return { success: false, backend: "ffmpeg", message: "FFmpeg not found." }; + } + if (!ffprobePath) { + return { success: false, backend: "ffmpeg", message: "FFprobe not found." }; + } + const fps = normalizeFps(options.fps); + const encoder = await selectEncoder(ffmpegPath); + const outputDir = path.dirname(options.outputPath); + await fs.mkdir(outputDir, { recursive: true }); + const captureArgs = buildCaptureArgs(options.target, fps); + const vf = buildVideoFilters(fps).join(","); + const args = [ + "-hide_banner", + "-y", + // 输出更稳定的 CFR + ...captureArgs, + "-vf", + vf, + ...buildEncoderArgs(encoder), + "-movflags", + "+faststart", + options.outputPath + ]; + const proc = spawn(ffmpegPath, args, { + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"] + }); + proc.stdout.on("data", () => { + }); + let stderrBuffer = ""; + proc.stderr.on("data", (chunk) => { + const text = String(chunk); + stderrBuffer += text; + const trimmed = text.trim(); + if (trimmed) console.log("[ffmpeg]", trimmed); + }); + return await new Promise((resolve) => { + const startedAt = Date.now(); + let settled = false; + const settle = (result) => { + if (settled) return; + settled = true; + resolve(result); + }; + const startupTimer = setTimeout(() => { + if (proc.exitCode !== null) { + settle({ + success: false, + backend: "ffmpeg", + message: stderrBuffer.trim() || `FFmpeg exited early with code ${proc.exitCode}.` + }); + return; + } + this.session = { proc, outputPath: options.outputPath, startedAt, encoder, ffmpegPath, ffprobePath }; + settle({ success: true, backend: "ffmpeg", ffmpegPath, ffprobePath, encoder }); + }, 400); + proc.on("error", (error) => { + clearTimeout(startupTimer); + settle({ success: false, backend: "ffmpeg", message: String(error) }); + }); + proc.on("close", (code) => { + var _a; + if (!settled) { + clearTimeout(startupTimer); + settle({ + success: false, + backend: "ffmpeg", + message: stderrBuffer.trim() || `FFmpeg exited with code ${code ?? "unknown"}.` + }); + return; + } + if (((_a = this.session) == null ? void 0 : _a.proc) === proc && code !== null && code !== 0) { + this.session = null; + } + }); + }); + } finally { + this.starting = false; + } + } + async stop() { + var _a; + if (!this.session) { + return { success: false, backend: "ffmpeg", message: "No active recording session." }; + } + if (this.stopping) { + return { success: false, backend: "ffmpeg", message: "Stop already in progress." }; + } + const activeSession = this.session; + const { proc, outputPath, ffprobePath } = activeSession; + this.stopping = true; + const waitForExit = async (timeoutMs) => { + return await new Promise((resolve) => { + const timer = setTimeout(() => resolve(null), timeoutMs); + proc.once("close", (code) => { + clearTimeout(timer); + resolve(typeof code === "number" ? code : null); + }); + }); + }; + try { + try { + proc.stdin.write("q"); + proc.stdin.end(); + } catch { + } + let exitCode = await waitForExit(6e3); + if (exitCode === null) { + try { + proc.kill("SIGKILL"); + } catch { + } + exitCode = await waitForExit(3e3); + } + if (!await fileExists(outputPath)) { + return { success: false, backend: "ffmpeg", message: "Recording did not produce an output file." }; + } + const probe = await runFfprobe(ffprobePath, outputPath, 4e3); + if (exitCode !== 0) { + return { + success: false, + backend: "ffmpeg", + message: `FFmpeg exited with code ${exitCode ?? "unknown"}.`, + path: outputPath, + probe: probe ?? void 0 + }; + } + return { success: true, backend: "ffmpeg", path: outputPath, probe: probe ?? void 0 }; + } catch (error) { + return { success: false, backend: "ffmpeg", message: String(error) }; + } finally { + if (((_a = this.session) == null ? void 0 : _a.proc) === activeSession.proc) { + this.session = null; + } + this.stopping = false; + } + } +} let selectedSource = null; function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) { + const ffmpegRecorder = new FfmpegRecorder({ recordingsDir: RECORDINGS_DIR }); ipcMain.handle("get-sources", async (_, opts) => { const sources = await desktopCapturer.getSources(opts); return sources.map((source) => ({ @@ -161,6 +667,99 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g } createEditorWindow2(); }); + ipcMain.handle("start-recording", async (_, options) => { + try { + if (!selectedSource) { + return { success: false, backend: "ffmpeg", message: tMain("Please select a source to record") }; + } + if (!selectedSource.id || typeof selectedSource.id !== "string") { + return { success: false, backend: "ffmpeg", message: tMain("Invalid recording source") }; + } + const platform = process.platform; + const sourceId = selectedSource.id; + const isScreenSource = sourceId.startsWith("screen:"); + const isWindowSource = sourceId.startsWith("window:"); + if (platform === "darwin" && !isScreenSource) { + return { + success: false, + backend: "ffmpeg", + message: tMain("FFmpeg recording currently supports only screen sources.") + }; + } + if (platform === "win32" && !isScreenSource && !isWindowSource) { + return { success: false, backend: "ffmpeg", message: tMain("Invalid recording source") }; + } + if (platform !== "win32" && platform !== "darwin") { + return { + success: false, + backend: "ffmpeg", + message: tMain("FFmpeg recording is not supported on this platform yet.") + }; + } + const fps = Number(options == null ? void 0 : options.fps) || 60; + const timestamp = Date.now(); + const fileName = `recording-${timestamp}.mp4`; + const outputPath = path.join(RECORDINGS_DIR, fileName); + const startResult = isScreenSource ? await (async () => { + const displays = screen.getAllDisplays(); + const displayId = String(selectedSource.display_id ?? ""); + const displayIndex = displays.findIndex((d) => String(d.id) === displayId); + const display = displayIndex >= 0 ? displays[displayIndex] : screen.getPrimaryDisplay(); + const scaleFactor = Number(display.scaleFactor) || 1; + const bounds = display.bounds; + const boundsPx = { + x: Math.round(bounds.x * scaleFactor), + y: Math.round(bounds.y * scaleFactor), + width: Math.round(bounds.width * scaleFactor), + height: Math.round(bounds.height * scaleFactor) + }; + boundsPx.width = boundsPx.width - boundsPx.width % 2; + boundsPx.height = boundsPx.height - boundsPx.height % 2; + return await ffmpegRecorder.start({ + fps, + outputPath, + target: { + kind: "screen", + boundsPx, + displayIndex: displayIndex >= 0 ? displayIndex : 0 + } + }); + })() : await (async () => { + const candidates = [selectedSource == null ? void 0 : selectedSource.rawName, selectedSource == null ? void 0 : selectedSource.name].filter((value) => typeof value === "string").map((value) => value.trim()).filter(Boolean); + const uniqueTitles = Array.from(new Set(candidates)); + if (uniqueTitles.length === 0) { + return { success: false, backend: "ffmpeg", message: tMain("Invalid recording source") }; + } + let result = null; + for (const title of uniqueTitles) { + result = await ffmpegRecorder.start({ fps, outputPath, target: { kind: "window", title } }); + if (result.success) break; + } + return result ?? { success: false, backend: "ffmpeg", message: tMain("Invalid recording source") }; + })(); + if (startResult.success && onRecordingStateChange) { + const sourceName = (selectedSource == null ? void 0 : selectedSource.name) || tMain("Screen"); + onRecordingStateChange(true, sourceName); + } + return startResult; + } catch (error) { + console.error("Failed to start ffmpeg recording:", error); + return { success: false, backend: "ffmpeg", message: tMain("Failed to start recording") }; + } + }); + ipcMain.handle("stop-recording", async () => { + try { + const stopResult = await ffmpegRecorder.stop(); + if (onRecordingStateChange) { + const sourceName = (selectedSource == null ? void 0 : selectedSource.name) || tMain("Screen"); + onRecordingStateChange(false, sourceName); + } + return stopResult; + } catch (error) { + console.error("Failed to stop ffmpeg recording:", error); + return { success: false, backend: "ffmpeg", message: tMain("Failed to stop recording") }; + } + }); ipcMain.handle("store-recorded-video", async (_, videoData, fileName) => { try { const videoPath = path.join(RECORDINGS_DIR, fileName); @@ -169,13 +768,13 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g return { success: true, path: videoPath, - message: "Video stored successfully" + message: tMain("Video stored successfully") }; } catch (error) { console.error("Failed to store video:", error); return { success: false, - message: "Failed to store video", + message: tMain("Failed to store video"), error: String(error) }; } @@ -183,20 +782,20 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g ipcMain.handle("get-recorded-video-path", async () => { try { const files = await fs.readdir(RECORDINGS_DIR); - const videoFiles = files.filter((file) => file.endsWith(".webm")); + const videoFiles = files.filter((file) => file.endsWith(".webm") || file.endsWith(".mp4")); if (videoFiles.length === 0) { - return { success: false, message: "No recorded video found" }; + return { success: false, message: tMain("No recorded video found") }; } const latestVideo = videoFiles.sort().reverse()[0]; const videoPath = path.join(RECORDINGS_DIR, latestVideo); return { success: true, path: videoPath }; } catch (error) { console.error("Failed to get video path:", error); - return { success: false, message: "Failed to get video path", error: String(error) }; + return { success: false, message: tMain("Failed to get video path"), error: String(error) }; } }); ipcMain.handle("set-recording-state", (_, recording) => { - const source = selectedSource || { name: "Screen" }; + const source = selectedSource || { name: tMain("Screen") }; if (onRecordingStateChange) { onRecordingStateChange(recording, source.name); } @@ -224,10 +823,10 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g ipcMain.handle("save-exported-video", async (_, videoData, fileName) => { try { const result = await dialog.showSaveDialog({ - title: "Save Exported Video", + title: tMain("Save Exported Video"), defaultPath: path.join(app.getPath("downloads"), fileName), filters: [ - { name: "MP4 Video", extensions: ["mp4"] } + { name: tMain("MP4 Video"), extensions: ["mp4"] } ], properties: ["createDirectory", "showOverwriteConfirmation"] }); @@ -235,20 +834,20 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g return { success: false, cancelled: true, - message: "Export cancelled" + message: tMain("Export cancelled") }; } await fs.writeFile(result.filePath, Buffer.from(videoData)); return { success: true, path: result.filePath, - message: "Video exported successfully" + message: tMain("Video exported successfully") }; } catch (error) { console.error("Failed to save exported video:", error); return { success: false, - message: "Failed to save exported video", + message: tMain("Failed to save exported video"), error: String(error) }; } @@ -256,11 +855,11 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g ipcMain.handle("open-video-file-picker", async () => { try { const result = await dialog.showOpenDialog({ - title: "Select Video File", + title: tMain("Select Video File"), defaultPath: RECORDINGS_DIR, filters: [ - { name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] }, - { name: "All Files", extensions: ["*"] } + { name: tMain("Video Files"), extensions: ["webm", "mp4", "mov", "avi", "mkv"] }, + { name: tMain("All Files"), extensions: ["*"] } ], properties: ["openFile"] }); @@ -275,7 +874,7 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g console.error("Failed to open file picker:", error); return { success: false, - message: "Failed to open file picker", + message: tMain("Failed to open file picker"), error: String(error) }; } @@ -316,31 +915,55 @@ let mainWindow = null; let sourceSelectorWindow = null; let tray = null; let selectedSourceName = ""; +const defaultTrayIcon = getTrayIcon("openscreen.png"); +const recordingTrayIcon = getTrayIcon("rec-button.png"); function createWindow() { mainWindow = createHudOverlayWindow(); } function createTray() { - const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, "rec-button.png"); - let icon = nativeImage.createFromPath(iconPath); - icon = icon.resize({ width: 24, height: 24, quality: "best" }); - tray = new Tray(icon); - updateTrayMenu(); + tray = new Tray(defaultTrayIcon); } -function updateTrayMenu() { +function getTrayIcon(filename) { + return nativeImage.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename)).resize({ + width: 24, + height: 24, + quality: "best" + }); +} +function updateTrayMenu(recording = false) { if (!tray) return; - const menuTemplate = [ + const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon; + const trayToolTip = recording ? tMain("Recording: {{source}}", { source: selectedSourceName }) : "OpenScreen"; + const menuTemplate = recording ? [ { - label: "Stop Recording", + label: tMain("Stop Recording"), click: () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send("stop-recording-from-tray"); } } } + ] : [ + { + label: tMain("Open"), + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.isMinimized() && mainWindow.restore(); + } else { + createWindow(); + } + } + }, + { + label: tMain("Quit"), + click: () => { + app.quit(); + } + } ]; - const contextMenu = Menu.buildFromTemplate(menuTemplate); - tray.setContextMenu(contextMenu); - tray.setToolTip(`Recording: ${selectedSourceName}`); + tray.setImage(trayIcon); + tray.setToolTip(trayToolTip); + tray.setContextMenu(Menu.buildFromTemplate(menuTemplate)); } function createEditorWindowWrapper() { if (mainWindow) { @@ -366,10 +989,10 @@ app.on("activate", () => { app.whenReady().then(async () => { const { ipcMain: ipcMain2 } = await import("electron"); ipcMain2.on("hud-overlay-close", () => { - if (process.platform === "darwin") { - app.quit(); - } + app.quit(); }); + createTray(); + updateTrayMenu(); await ensureRecordingsDir(); registerIpcHandlers( createEditorWindowWrapper, @@ -378,14 +1001,9 @@ app.whenReady().then(async () => { () => sourceSelectorWindow, (recording, sourceName) => { selectedSourceName = sourceName; - if (recording) { - if (!tray) createTray(); - updateTrayMenu(); - } else { - if (tray) { - tray.destroy(); - tray = null; - } + if (!tray) createTray(); + updateTrayMenu(recording); + if (!recording) { if (mainWindow) mainWindow.restore(); } } diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index cb59604..1305f24 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -31,6 +31,12 @@ electron.contextBridge.exposeInMainWorld("electronAPI", { getRecordedVideoPath: () => { return electron.ipcRenderer.invoke("get-recorded-video-path"); }, + startRecording: (options) => { + return electron.ipcRenderer.invoke("start-recording", options); + }, + stopRecording: () => { + return electron.ipcRenderer.invoke("stop-recording"); + }, setRecordingState: (recording) => { return electron.ipcRenderer.invoke("set-recording-state", recording); }, diff --git a/electron-builder.json5 b/electron-builder.json5 index 7031cc6..5b85c93 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -49,6 +49,7 @@ "target": [ "nsis" ], - "icon": "icons/icons/win/icon.ico" + "icon": "icons/icons/win/icon.ico", + "artifactName": "${productName}-Windows-${version}-Setup.${ext}" } } diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index dba3f16..e168cb3 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -22,33 +22,70 @@ declare namespace NodeJS { } // Used in Renderer process, expose in `preload.ts` -interface Window { - electronAPI: { - getSources: (opts: Electron.SourcesOptions) => Promise - switchToEditor: () => Promise - openSourceSelector: () => Promise - selectSource: (source: any) => Promise - getSelectedSource: () => Promise - storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }> - getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }> - setRecordingState: (recording: boolean) => Promise - onStopRecordingFromTray: (callback: () => void) => () => void - openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }> - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string; cancelled?: boolean }> - openVideoFilePicker: () => Promise<{ success: boolean; path?: string; cancelled?: boolean }> - setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> - getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> - clearCurrentVideoPath: () => Promise<{ success: boolean }> - getPlatform: () => Promise +interface Window { + electronAPI: { + getSources: (opts: Electron.SourcesOptions) => Promise + switchToEditor: () => Promise + openSourceSelector: () => Promise + selectSource: (source: SelectedSource) => Promise + getSelectedSource: () => Promise + storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }> + getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }> + startRecording: (options?: { fps?: number }) => Promise<{ + success: boolean; + backend: 'ffmpeg'; + message?: string; + ffmpegPath?: string; + ffprobePath?: string; + encoder?: string; + }> + stopRecording: () => Promise<{ + success: boolean; + backend: 'ffmpeg'; + message?: string; + path?: string; + probe?: { + formatName?: string; + durationSeconds?: number; + sizeBytes?: number; + video?: { + codec?: string; + width?: number; + height?: number; + avgFrameRate?: number; + rFrameRate?: number; + bitRate?: number; + pixFmt?: string; + }; + }; + }> + setRecordingState: (recording: boolean) => Promise + onStopRecordingFromTray: (callback: () => void) => () => void + openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }> + saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string; cancelled?: boolean }> + openVideoFilePicker: () => Promise<{ success: boolean; path?: string; cancelled?: boolean }> + setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> + getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> + clearCurrentVideoPath: () => Promise<{ success: boolean }> + getPlatform: () => Promise hudOverlayHide: () => void; hudOverlayClose: () => void; } } -interface ProcessedDesktopSource { - id: string - name: string - display_id: string - thumbnail: string | null - appIcon: string | null -} +interface ProcessedDesktopSource { + id: string + name: string + display_id: string + thumbnail: string | null + appIcon: string | null +} + +interface SelectedSource { + id: string + name: string + rawName?: string + display_id?: string + thumbnail?: string | null + appIcon?: string | null +} diff --git a/electron/i18n.ts b/electron/i18n.ts new file mode 100644 index 0000000..953eda0 --- /dev/null +++ b/electron/i18n.ts @@ -0,0 +1,68 @@ +import { app } from 'electron' + +export type Language = 'en' | 'zh-CN' + +type InterpolationValues = Record + +function interpolate(template: string, values?: InterpolationValues): string { + if (!values) return template + return template.replace(/{{\s*([a-zA-Z0-9_]+)\s*}}/g, (_match, key: string) => { + const value = values[key] + return value === undefined || value === null ? '' : String(value) + }) +} + +function normalizeLanguage(value: string | null | undefined): Language | null { + if (!value) return null + const lower = value.toLowerCase() + if (lower === 'zh' || lower === 'zh-cn' || lower.startsWith('zh-')) return 'zh-CN' + if (lower === 'en' || lower.startsWith('en-')) return 'en' + return null +} + +export function getAppLanguage(): Language { + try { + return normalizeLanguage(app.getLocale?.()) ?? 'en' + } catch { + return 'en' + } +} + +const zhCN: Record = { + 'Recording: {{source}}': '正在录制:{{source}}', + 'Stop Recording': '停止录制', + 'Open': '打开', + 'Quit': '退出', + + 'Save Exported Video': '保存导出的视频', + 'MP4 Video': 'MP4 视频', + 'Export cancelled': '已取消导出', + 'Video exported successfully': '视频导出成功', + 'Failed to save exported video': '保存导出的视频失败', + + 'Select Video File': '选择视频文件', + 'Video Files': '视频文件', + 'All Files': '所有文件', + 'Failed to open file picker': '打开文件选择器失败', + + 'Video stored successfully': '视频保存成功', + 'Failed to store video': '保存视频失败', + 'No recorded video found': '未找到录制视频', + 'Failed to get video path': '获取视频路径失败', + + 'Please select a source to record': '请先选择要录制的来源', + 'Invalid recording source': '录制来源无效', + 'FFmpeg recording currently supports only screen sources.': 'FFmpeg 录制目前仅支持屏幕来源。', + 'FFmpeg recording is not supported on this platform yet.': 'FFmpeg 录制暂不支持当前平台。', + 'Failed to start recording': '开始录制失败', + 'Failed to stop recording': '停止录制失败', + + 'Screen': '屏幕', +} + +export function tMain(key: string, values?: InterpolationValues): string { + const language = getAppLanguage() + const template = language === 'zh-CN' ? (zhCN[key] ?? key) : key + return interpolate(template, values) +} + diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 34c9886..30065ec 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,10 +1,21 @@ -import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog } from 'electron' +import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog, screen } from 'electron' import fs from 'node:fs/promises' import path from 'node:path' import { RECORDINGS_DIR } from '../main' +import { tMain } from '../i18n' +import { FfmpegRecorder } from '../recording/ffmpegRecorder' -let selectedSource: any = null +type SelectedSource = { + id: string + name: string + rawName?: string + display_id?: string + thumbnail?: string | null + appIcon?: string | null +} + +let selectedSource: SelectedSource | null = null export function registerIpcHandlers( createEditorWindow: () => void, @@ -13,6 +24,8 @@ export function registerIpcHandlers( getSourceSelectorWindow: () => BrowserWindow | null, onRecordingStateChange?: (recording: boolean, sourceName: string) => void ) { + const ffmpegRecorder = new FfmpegRecorder({ recordingsDir: RECORDINGS_DIR }) + ipcMain.handle('get-sources', async (_, opts) => { const sources = await desktopCapturer.getSources(opts) return sources.map(source => ({ @@ -54,6 +67,125 @@ export function registerIpcHandlers( createEditorWindow() }) + ipcMain.handle('start-recording', async (_, options?: { fps?: number }) => { + try { + if (!selectedSource) { + return { success: false, backend: 'ffmpeg', message: tMain('Please select a source to record') } + } + + if (!selectedSource.id || typeof selectedSource.id !== 'string') { + return { success: false, backend: 'ffmpeg', message: tMain('Invalid recording source') } + } + + const platform = process.platform + const sourceId = selectedSource.id + const isScreenSource = sourceId.startsWith('screen:') + const isWindowSource = sourceId.startsWith('window:') + + if (platform === 'darwin' && !isScreenSource) { + return { + success: false, + backend: 'ffmpeg', + message: tMain('FFmpeg recording currently supports only screen sources.'), + } + } + + if (platform === 'win32' && !isScreenSource && !isWindowSource) { + return { success: false, backend: 'ffmpeg', message: tMain('Invalid recording source') } + } + + if (platform !== 'win32' && platform !== 'darwin') { + return { + success: false, + backend: 'ffmpeg', + message: tMain('FFmpeg recording is not supported on this platform yet.'), + } + } + + const fps = Number(options?.fps) || 60 + const timestamp = Date.now() + const fileName = `recording-${timestamp}.mp4` + const outputPath = path.join(RECORDINGS_DIR, fileName) + + const startResult = isScreenSource + ? await (async () => { + const displays = screen.getAllDisplays() + const displayId = String(selectedSource.display_id ?? '') + const displayIndex = displays.findIndex(d => String(d.id) === displayId) + const display = displayIndex >= 0 ? displays[displayIndex] : screen.getPrimaryDisplay() + + const scaleFactor = Number(display.scaleFactor) || 1 + const bounds = display.bounds + const boundsPx = { + x: Math.round(bounds.x * scaleFactor), + y: Math.round(bounds.y * scaleFactor), + width: Math.round(bounds.width * scaleFactor), + height: Math.round(bounds.height * scaleFactor), + } + // 保证编码兼容:宽高为偶数 + boundsPx.width = boundsPx.width - (boundsPx.width % 2) + boundsPx.height = boundsPx.height - (boundsPx.height % 2) + + return await ffmpegRecorder.start({ + fps, + outputPath, + target: { + kind: 'screen', + boundsPx, + displayIndex: displayIndex >= 0 ? displayIndex : 0, + }, + }) + })() + : await (async () => { + const candidates = [selectedSource?.rawName, selectedSource?.name] + .filter((value: unknown): value is string => typeof value === 'string') + .map(value => value.trim()) + .filter(Boolean) + + const uniqueTitles = Array.from(new Set(candidates)) + if (uniqueTitles.length === 0) { + return { success: false, backend: 'ffmpeg', message: tMain('Invalid recording source') } + } + + let result = null as Awaited> | null + for (const title of uniqueTitles) { + result = await ffmpegRecorder.start({ fps, outputPath, target: { kind: 'window', title } }) + if (result.success) break + } + + return ( + result ?? { success: false, backend: 'ffmpeg', message: tMain('Invalid recording source') } + ) + })() + + if (startResult.success && onRecordingStateChange) { + const sourceName = selectedSource?.name || tMain('Screen') + onRecordingStateChange(true, sourceName) + } + + return startResult + } catch (error) { + console.error('Failed to start ffmpeg recording:', error) + return { success: false, backend: 'ffmpeg', message: tMain('Failed to start recording') } + } + }) + + ipcMain.handle('stop-recording', async () => { + try { + const stopResult = await ffmpegRecorder.stop() + + if (onRecordingStateChange) { + const sourceName = selectedSource?.name || tMain('Screen') + onRecordingStateChange(false, sourceName) + } + + return stopResult + } catch (error) { + console.error('Failed to stop ffmpeg recording:', error) + return { success: false, backend: 'ffmpeg', message: tMain('Failed to stop recording') } + } + }) + ipcMain.handle('store-recorded-video', async (_, videoData: ArrayBuffer, fileName: string) => { @@ -64,13 +196,13 @@ export function registerIpcHandlers( return { success: true, path: videoPath, - message: 'Video stored successfully' + message: tMain('Video stored successfully') } } catch (error) { console.error('Failed to store video:', error) return { success: false, - message: 'Failed to store video', + message: tMain('Failed to store video'), error: String(error) } } @@ -81,10 +213,10 @@ export function registerIpcHandlers( ipcMain.handle('get-recorded-video-path', async () => { try { const files = await fs.readdir(RECORDINGS_DIR) - const videoFiles = files.filter(file => file.endsWith('.webm')) + const videoFiles = files.filter(file => file.endsWith('.webm') || file.endsWith('.mp4')) if (videoFiles.length === 0) { - return { success: false, message: 'No recorded video found' } + return { success: false, message: tMain('No recorded video found') } } const latestVideo = videoFiles.sort().reverse()[0] @@ -93,12 +225,12 @@ export function registerIpcHandlers( return { success: true, path: videoPath } } catch (error) { console.error('Failed to get video path:', error) - return { success: false, message: 'Failed to get video path', error: String(error) } + return { success: false, message: tMain('Failed to get video path'), error: String(error) } } }) ipcMain.handle('set-recording-state', (_, recording: boolean) => { - const source = selectedSource || { name: 'Screen' } + const source = selectedSource || { name: tMain('Screen') } if (onRecordingStateChange) { onRecordingStateChange(recording, source.name) } @@ -131,10 +263,10 @@ export function registerIpcHandlers( ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => { try { const result = await dialog.showSaveDialog({ - title: 'Save Exported Video', + title: tMain('Save Exported Video'), defaultPath: path.join(app.getPath('downloads'), fileName), filters: [ - { name: 'MP4 Video', extensions: ['mp4'] } + { name: tMain('MP4 Video'), extensions: ['mp4'] } ], properties: ['createDirectory', 'showOverwriteConfirmation'] }); @@ -143,7 +275,7 @@ export function registerIpcHandlers( return { success: false, cancelled: true, - message: 'Export cancelled' + message: tMain('Export cancelled') }; } await fs.writeFile(result.filePath, Buffer.from(videoData)); @@ -151,13 +283,13 @@ export function registerIpcHandlers( return { success: true, path: result.filePath, - message: 'Video exported successfully' + message: tMain('Video exported successfully') }; } catch (error) { console.error('Failed to save exported video:', error) return { success: false, - message: 'Failed to save exported video', + message: tMain('Failed to save exported video'), error: String(error) } } @@ -166,11 +298,11 @@ export function registerIpcHandlers( ipcMain.handle('open-video-file-picker', async () => { try { const result = await dialog.showOpenDialog({ - title: 'Select Video File', + title: tMain('Select Video File'), defaultPath: RECORDINGS_DIR, filters: [ - { name: 'Video Files', extensions: ['webm', 'mp4', 'mov', 'avi', 'mkv'] }, - { name: 'All Files', extensions: ['*'] } + { name: tMain('Video Files'), extensions: ['webm', 'mp4', 'mov', 'avi', 'mkv'] }, + { name: tMain('All Files'), extensions: ['*'] } ], properties: ['openFile'] }); @@ -187,7 +319,7 @@ export function registerIpcHandlers( console.error('Failed to open file picker:', error); return { success: false, - message: 'Failed to open file picker', + message: tMain('Failed to open file picker'), error: String(error) }; } diff --git a/electron/main.ts b/electron/main.ts index e40e08b..3415b7a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,6 +4,7 @@ import path from 'node:path' import fs from 'node:fs/promises' import { createHudOverlayWindow, createEditorWindow, createSourceSelectorWindow } from './windows' import { registerIpcHandlers } from './ipc/handlers' +import { tMain } from './i18n' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -69,11 +70,11 @@ function getTrayIcon(filename: string) { function updateTrayMenu(recording: boolean = false) { if (!tray) return; const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon; - const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen"; + const trayToolTip = recording ? tMain('Recording: {{source}}', { source: selectedSourceName }) : "OpenScreen"; const menuTemplate = recording ? [ { - label: "Stop Recording", + label: tMain("Stop Recording"), click: () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send("stop-recording-from-tray"); @@ -83,7 +84,7 @@ function updateTrayMenu(recording: boolean = false) { ] : [ { - label: "Open", + label: tMain("Open"), click: () => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.isMinimized() && mainWindow.restore(); @@ -93,7 +94,7 @@ function updateTrayMenu(recording: boolean = false) { }, }, { - label: "Quit", + label: tMain("Quit"), click: () => { app.quit(); }, diff --git a/electron/preload.ts b/electron/preload.ts index 02fcc97..52e8d06 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -20,7 +20,7 @@ contextBridge.exposeInMainWorld('electronAPI', { openSourceSelector: () => { return ipcRenderer.invoke('open-source-selector') }, - selectSource: (source: any) => { + selectSource: (source: unknown) => { return ipcRenderer.invoke('select-source', source) }, getSelectedSource: () => { @@ -34,6 +34,14 @@ contextBridge.exposeInMainWorld('electronAPI', { getRecordedVideoPath: () => { return ipcRenderer.invoke('get-recorded-video-path') }, + + startRecording: (options?: { fps?: number }) => { + return ipcRenderer.invoke('start-recording', options) + }, + + stopRecording: () => { + return ipcRenderer.invoke('stop-recording') + }, setRecordingState: (recording: boolean) => { return ipcRenderer.invoke('set-recording-state', recording) }, @@ -63,4 +71,4 @@ contextBridge.exposeInMainWorld('electronAPI', { getPlatform: () => { return ipcRenderer.invoke('get-platform') }, -}) \ No newline at end of file +}) diff --git a/electron/recording/ffmpegRecorder.ts b/electron/recording/ffmpegRecorder.ts new file mode 100644 index 0000000..6927bd2 --- /dev/null +++ b/electron/recording/ffmpegRecorder.ts @@ -0,0 +1,598 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { access } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; + +export type RecordingBackend = 'ffmpeg'; + +export type RecordingTarget = + | { + kind: 'screen'; + boundsPx: { x: number; y: number; width: number; height: number }; + displayIndex?: number; + } + | { + kind: 'window'; + title: string; + }; + +export type StartRecordingOptions = { + fps: number; + outputPath: string; + target: RecordingTarget; +}; + +export type RecordingProbe = { + formatName?: string; + durationSeconds?: number; + sizeBytes?: number; + video?: { + codec?: string; + width?: number; + height?: number; + avgFrameRate?: number; + rFrameRate?: number; + bitRate?: number; + pixFmt?: string; + }; +}; + +export type StartRecordingResult = { + success: boolean; + backend: RecordingBackend; + message?: string; + ffmpegPath?: string; + ffprobePath?: string; + encoder?: string; +}; + +export type StopRecordingResult = { + success: boolean; + backend: RecordingBackend; + message?: string; + path?: string; + probe?: RecordingProbe; +}; + +type RecorderConfig = { + recordingsDir: string; + ffmpegPath?: string; + ffprobePath?: string; +}; + +type ActiveSession = { + proc: ChildProcessWithoutNullStreams; + outputPath: string; + startedAt: number; + encoder: string; + ffmpegPath: string; + ffprobePath: string; +}; + +function normalizeFps(fps: number): number { + if (!Number.isFinite(fps) || fps <= 0) return 60; + return Math.round(fps); +} + +function ensureEven(value: number): number { + if (!Number.isFinite(value)) return 0; + const rounded = Math.round(value); + return rounded - (rounded % 2); +} + +async function fileExists(filePath: string): Promise { + try { + await access(filePath, fsConstants.F_OK); + return true; + } catch { + return false; + } +} + +async function resolveBinary(explicitPath: string | undefined, fallback: string): Promise { + if (explicitPath && (await fileExists(explicitPath))) return explicitPath; + return fallback; +} + +async function probeEncoder( + ffmpegPath: string, + encoder: string, + timeoutMs: number +): Promise<{ ok: boolean; error?: string }> { + return await new Promise(resolve => { + const args = [ + '-hide_banner', + '-loglevel', + 'error', + '-f', + 'lavfi', + '-i', + 'testsrc2=size=128x128:rate=30', + '-t', + '0.2', + '-c:v', + encoder, + '-f', + 'null', + '-', + ]; + + const proc = spawn(ffmpegPath, args, { windowsHide: true }); + + let stderr = ''; + const timer = setTimeout(() => { + try { + proc.kill('SIGKILL'); + } catch { + // ignore + } + resolve({ ok: false, error: 'encoder probe timeout' }); + }, timeoutMs); + + proc.stderr.on('data', chunk => { + stderr += String(chunk); + }); + + proc.on('error', err => { + clearTimeout(timer); + resolve({ ok: false, error: String(err) }); + }); + + proc.on('close', code => { + clearTimeout(timer); + resolve({ ok: code === 0, error: code === 0 ? undefined : (stderr.trim() || `exit ${code ?? 'unknown'}`) }); + }); + }); +} + +async function selectEncoder(ffmpegPath: string): Promise { + const platform = process.platform; + const candidates: string[] = + platform === 'win32' + ? ['h264_nvenc', 'h264_amf', 'h264_qsv', 'libx264'] + : platform === 'darwin' + ? ['h264_videotoolbox', 'libx264'] + : ['libx264']; + + for (const encoder of candidates) { + const { ok } = await probeEncoder(ffmpegPath, encoder, 4_000); + if (ok) return encoder; + } + + return 'libx264'; +} + +function buildVideoFilters(fps: number): string[] { + const forcedFps = normalizeFps(fps); + return [ + `fps=${forcedFps}`, + 'format=yuv420p', + // 保证宽高为偶数,避免编码器因奇数尺寸失败 + 'scale=trunc(iw/2)*2:trunc(ih/2)*2', + ]; +} + +function buildEncoderArgs(encoder: string): string[] { + switch (encoder) { + case 'h264_nvenc': + return [ + '-c:v', + 'h264_nvenc', + '-preset', + 'p7', + '-rc', + 'vbr', + '-cq', + '18', + '-b:v', + '0', + ]; + case 'h264_videotoolbox': + return [ + '-c:v', + 'h264_videotoolbox', + // 以较高码率优先保证清晰度(接近无损);具体码率仍会因设备能力调整 + '-b:v', + '60000k', + ]; + case 'h264_amf': + return [ + '-c:v', + 'h264_amf', + '-quality', + 'quality', + '-rc', + 'cqp', + '-qp_i', + '18', + '-qp_p', + '18', + ]; + case 'h264_qsv': + return [ + '-c:v', + 'h264_qsv', + '-global_quality', + '18', + ]; + case 'libx264': + default: + return [ + '-c:v', + 'libx264', + // 作为软件回退,优先保证能跑满帧率,文件体积会更大 + '-preset', + 'ultrafast', + '-crf', + '18', + '-tune', + 'stillimage', + ]; + } +} + +function buildCaptureArgs(target: RecordingTarget, fps: number): string[] { + const forcedFps = normalizeFps(fps); + const platform = process.platform; + + if (platform === 'win32') { + if (target.kind === 'screen') { + const width = ensureEven(target.boundsPx.width); + const height = ensureEven(target.boundsPx.height); + const x = Math.round(target.boundsPx.x); + const y = Math.round(target.boundsPx.y); + return [ + '-f', + 'gdigrab', + '-framerate', + String(forcedFps), + '-draw_mouse', + '1', + '-offset_x', + String(x), + '-offset_y', + String(y), + '-video_size', + `${width}x${height}`, + '-i', + 'desktop', + ]; + } + + if (target.kind === 'window') { + const title = target.title?.trim(); + if (!title) { + throw new Error('Invalid window title.'); + } + // gdigrab 支持 title=xxx 方式按窗口标题捕获 + return [ + '-f', + 'gdigrab', + '-framerate', + String(forcedFps), + '-draw_mouse', + '1', + '-i', + `title=${title}`, + ]; + } + } + + if (platform === 'darwin') { + if (target.kind === 'screen') { + const index = typeof target.displayIndex === 'number' ? target.displayIndex : 0; + // avfoundation 设备名通常是 “Capture screen N” + const device = `Capture screen ${index}`; + return [ + '-f', + 'avfoundation', + '-framerate', + String(forcedFps), + '-i', + `${device}:none`, + ]; + } + } + + throw new Error('Unsupported recording target for current platform.'); +} + +async function runFfprobe(ffprobePath: string, filePath: string, timeoutMs: number): Promise { + return await new Promise(resolve => { + const args = [ + '-v', + 'error', + '-print_format', + 'json', + '-show_format', + '-show_streams', + filePath, + ]; + + const proc = spawn(ffprobePath, args, { windowsHide: true }); + let stdout = ''; + let stderr = ''; + + const timer = setTimeout(() => { + try { + proc.kill('SIGKILL'); + } catch { + // ignore + } + resolve(null); + }, timeoutMs); + + proc.stdout.on('data', chunk => { + stdout += String(chunk); + }); + proc.stderr.on('data', chunk => { + stderr += String(chunk); + }); + + proc.on('error', () => { + clearTimeout(timer); + resolve(null); + }); + + proc.on('close', code => { + clearTimeout(timer); + if (code !== 0) { + console.warn('ffprobe failed:', stderr.trim()); + resolve(null); + return; + } + try { + const parsed = JSON.parse(stdout) as unknown; + const root = + typeof parsed === 'object' && parsed !== null ? (parsed as Record) : {}; + const format = + typeof root.format === 'object' && root.format !== null + ? (root.format as Record) + : ({} as Record); + const streams = Array.isArray(root.streams) ? root.streams : []; + const rawVideoStream = + streams.find( + (value): value is Record => + typeof value === 'object' && + value !== null && + (value as Record).codec_type === 'video' + ) ?? ({} as Record); + const videoStream = rawVideoStream; + + const parseRate = (value: string | undefined): number | undefined => { + if (!value || typeof value !== 'string') return undefined; + const [numStr, denStr] = value.split('/'); + const num = Number(numStr); + const den = Number(denStr); + if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return undefined; + return num / den; + }; + + const sizeBytes = Number(format.size); + const durationSeconds = Number(format.duration); + const result: RecordingProbe = { + formatName: typeof format.format_name === 'string' ? format.format_name : undefined, + durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : undefined, + sizeBytes: Number.isFinite(sizeBytes) ? sizeBytes : undefined, + video: { + codec: typeof videoStream.codec_name === 'string' ? videoStream.codec_name : undefined, + width: Number.isFinite(Number(videoStream.width)) ? Number(videoStream.width) : undefined, + height: Number.isFinite(Number(videoStream.height)) ? Number(videoStream.height) : undefined, + avgFrameRate: parseRate( + typeof videoStream.avg_frame_rate === 'string' ? videoStream.avg_frame_rate : undefined + ), + rFrameRate: parseRate( + typeof videoStream.r_frame_rate === 'string' ? videoStream.r_frame_rate : undefined + ), + bitRate: Number.isFinite(Number(videoStream.bit_rate)) ? Number(videoStream.bit_rate) : undefined, + pixFmt: typeof videoStream.pix_fmt === 'string' ? videoStream.pix_fmt : undefined, + }, + }; + resolve(result); + } catch (error) { + console.warn('Failed to parse ffprobe output:', error); + resolve(null); + } + }); + }); +} + +export class FfmpegRecorder { + private config: RecorderConfig; + private session: ActiveSession | null = null; + private starting = false; + private stopping = false; + + constructor(config: RecorderConfig) { + this.config = config; + } + + isRecording(): boolean { + return this.session !== null; + } + + async start(options: StartRecordingOptions): Promise { + if (this.session || this.starting || this.stopping) { + return { success: false, backend: 'ffmpeg', message: 'Recording already in progress.' }; + } + + this.starting = true; + + try { + const ffmpegPath = await resolveBinary( + this.config.ffmpegPath || process.env.OPENSCREEN_FFMPEG_PATH, + 'ffmpeg' + ); + const ffprobePath = await resolveBinary( + this.config.ffprobePath || process.env.OPENSCREEN_FFPROBE_PATH, + 'ffprobe' + ); + + if (!ffmpegPath) { + return { success: false, backend: 'ffmpeg', message: 'FFmpeg not found.' }; + } + if (!ffprobePath) { + return { success: false, backend: 'ffmpeg', message: 'FFprobe not found.' }; + } + + const fps = normalizeFps(options.fps); + const encoder = await selectEncoder(ffmpegPath); + + const outputDir = path.dirname(options.outputPath); + await fs.mkdir(outputDir, { recursive: true }); + + const captureArgs = buildCaptureArgs(options.target, fps); + const vf = buildVideoFilters(fps).join(','); + const args = [ + '-hide_banner', + '-y', + // 输出更稳定的 CFR + ...captureArgs, + '-vf', + vf, + ...buildEncoderArgs(encoder), + '-movflags', + '+faststart', + options.outputPath, + ]; + + const proc = spawn(ffmpegPath, args, { + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // 避免 stdout 缓冲区占满导致子进程阻塞(正常情况下 ffmpeg 不会输出大量 stdout) + proc.stdout.on('data', () => { + // ignore + }); + + let stderrBuffer = ''; + proc.stderr.on('data', chunk => { + // 录制失败时这些日志非常关键;生产环境可考虑做节流或落盘 + const text = String(chunk); + stderrBuffer += text; + const trimmed = text.trim(); + if (trimmed) console.log('[ffmpeg]', trimmed); + }); + + return await new Promise(resolve => { + const startedAt = Date.now(); + let settled = false; + + const settle = (result: StartRecordingResult) => { + if (settled) return; + settled = true; + resolve(result); + }; + + const startupTimer = setTimeout(() => { + // 录制任务预期会一直运行直到用户手动停止;若在启动窗口内就已退出(即便 exitCode=0),也应视为失败 + if (proc.exitCode !== null) { + settle({ + success: false, + backend: 'ffmpeg', + message: stderrBuffer.trim() || `FFmpeg exited early with code ${proc.exitCode}.`, + }); + return; + } + + this.session = { proc, outputPath: options.outputPath, startedAt, encoder, ffmpegPath, ffprobePath }; + settle({ success: true, backend: 'ffmpeg', ffmpegPath, ffprobePath, encoder }); + }, 400); + + proc.on('error', error => { + clearTimeout(startupTimer); + settle({ success: false, backend: 'ffmpeg', message: String(error) }); + }); + + proc.on('close', code => { + if (!settled) { + clearTimeout(startupTimer); + settle({ + success: false, + backend: 'ffmpeg', + message: stderrBuffer.trim() || `FFmpeg exited with code ${code ?? 'unknown'}.`, + }); + return; + } + + // 若录制过程中 ffmpeg 意外退出,则清理 session + if (this.session?.proc === proc && code !== null && code !== 0) { + this.session = null; + } + }); + }); + } finally { + this.starting = false; + } + } + + async stop(): Promise { + if (!this.session) { + return { success: false, backend: 'ffmpeg', message: 'No active recording session.' }; + } + + if (this.stopping) { + return { success: false, backend: 'ffmpeg', message: 'Stop already in progress.' }; + } + + const activeSession = this.session; + const { proc, outputPath, ffprobePath } = activeSession; + this.stopping = true; + + const waitForExit = async (timeoutMs: number): Promise => { + return await new Promise(resolve => { + const timer = setTimeout(() => resolve(null), timeoutMs); + proc.once('close', code => { + clearTimeout(timer); + resolve(typeof code === 'number' ? code : null); + }); + }); + }; + + try { + // 优雅停止:向 ffmpeg 发送 “q” + try { + proc.stdin.write('q'); + proc.stdin.end(); + } catch { + // ignore + } + + let exitCode = await waitForExit(6_000); + if (exitCode === null) { + try { + proc.kill('SIGKILL'); + } catch { + // ignore + } + exitCode = await waitForExit(3_000); + } + + if (!(await fileExists(outputPath))) { + return { success: false, backend: 'ffmpeg', message: 'Recording did not produce an output file.' }; + } + + const probe = await runFfprobe(ffprobePath, outputPath, 4_000); + if (exitCode !== 0) { + return { + success: false, + backend: 'ffmpeg', + message: `FFmpeg exited with code ${exitCode ?? 'unknown'}.`, + path: outputPath, + probe: probe ?? undefined, + }; + } + + return { success: true, backend: 'ffmpeg', path: outputPath, probe: probe ?? undefined }; + } catch (error) { + return { success: false, backend: 'ffmpeg', message: String(error) }; + } finally { + if (this.session?.proc === activeSession.proc) { + this.session = null; + } + this.stopping = false; + } + } +} diff --git a/package-lock.json b/package-lock.json index 1322701..842cc40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.0.1", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.0.1", + "version": "1.1.1", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", @@ -51,7 +51,7 @@ "@typescript-eslint/parser": "^7.1.1", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.21", - "electron": "^30.0.1", + "electron": "^30.5.1", "electron-builder": "^24.13.3", "electron-icon-builder": "^2.0.1", "electron-rebuild": "^3.2.9", diff --git a/package.json b/package.json index 7e092b1..0790611 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "openscreen", "private": true, - "version": "1.0.2", + "version": "1.1.1", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build && electron-builder", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", + "verify:ffmpeg": "node scripts/verify-ffmpeg.mjs", "build:mac": "tsc && vite build && electron-builder --mac", "build:win": "tsc && vite build && electron-builder --win", "build:linux": "tsc && vite build && electron-builder --linux" @@ -56,7 +57,7 @@ "@typescript-eslint/parser": "^7.1.1", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.21", - "electron": "^30.0.1", + "electron": "^30.5.1", "electron-builder": "^24.13.3", "electron-icon-builder": "^2.0.1", "electron-rebuild": "^3.2.9", diff --git a/scripts/verify-ffmpeg.mjs b/scripts/verify-ffmpeg.mjs new file mode 100644 index 0000000..c2bd64c --- /dev/null +++ b/scripts/verify-ffmpeg.mjs @@ -0,0 +1,146 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +const run = async (cmd, args, { timeoutMs = 60_000 } = {}) => { + return await new Promise((resolve, reject) => { + const proc = spawn(cmd, args, { windowsHide: true }); + let stdout = ''; + let stderr = ''; + + const timer = setTimeout(() => { + try { + proc.kill('SIGKILL'); + } catch { + // ignore + } + reject(new Error(`${cmd} 超时(${timeoutMs}ms)`)); + }, timeoutMs); + + proc.stdout.on('data', chunk => { + stdout += String(chunk); + }); + proc.stderr.on('data', chunk => { + stderr += String(chunk); + }); + + proc.on('error', err => { + clearTimeout(timer); + reject(err); + }); + + proc.on('close', code => { + clearTimeout(timer); + resolve({ code, stdout, stderr }); + }); + }); +}; + +const parseRate = (value) => { + if (!value || typeof value !== 'string') return null; + const [n, d] = value.split('/'); + const num = Number(n); + const den = Number(d); + if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return null; + return num / den; +}; + +const main = async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openscreen-ffmpeg-check-')); + const outPath = path.join(tmpDir, `ffmpeg-check-${Date.now()}.mp4`); + + try { + const width = 1280; + const height = 720; + const fps = 60; + const durationSeconds = 2; + + const ffmpegArgs = [ + '-hide_banner', + '-y', + '-loglevel', + 'error', + '-f', + 'lavfi', + '-i', + `testsrc2=size=${width}x${height}:rate=${fps}`, + '-t', + String(durationSeconds), + '-vf', + `fps=${fps},format=yuv420p,scale=trunc(iw/2)*2:trunc(ih/2)*2`, + '-c:v', + 'libx264', + '-preset', + 'ultrafast', + '-crf', + '18', + '-tune', + 'stillimage', + '-movflags', + '+faststart', + outPath, + ]; + + const ffmpeg = await run('ffmpeg', ffmpegArgs, { timeoutMs: 60_000 }); + if (ffmpeg.code !== 0) { + throw new Error(`ffmpeg 执行失败(code=${ffmpeg.code})\n${ffmpeg.stderr.trim()}`); + } + + const ffprobeArgs = [ + '-v', + 'error', + '-print_format', + 'json', + '-show_format', + '-show_streams', + outPath, + ]; + const ffprobe = await run('ffprobe', ffprobeArgs, { timeoutMs: 15_000 }); + if (ffprobe.code !== 0) { + throw new Error(`ffprobe 执行失败(code=${ffprobe.code})\n${ffprobe.stderr.trim()}`); + } + + const json = JSON.parse(ffprobe.stdout); + const streams = Array.isArray(json.streams) ? json.streams : []; + const video = streams.find(s => s?.codec_type === 'video') ?? {}; + const format = json?.format ?? {}; + + const actualWidth = Number(video.width); + const actualHeight = Number(video.height); + const avgFps = parseRate(video.avg_frame_rate); + const codec = video.codec_name; + const duration = Number(format.duration); + + const errors = []; + if (codec !== 'h264') errors.push(`编码器不符合预期:${codec}`); + if (actualWidth !== width || actualHeight !== height) { + errors.push(`分辨率不符合预期:${actualWidth}x${actualHeight}`); + } + if (avgFps === null || Math.abs(avgFps - fps) > 0.5) errors.push(`帧率不符合预期:${avgFps}`); + if (!Number.isFinite(duration) || Math.abs(duration - durationSeconds) > 0.6) { + errors.push(`时长不符合预期:${duration}`); + } + + if (errors.length > 0) { + throw new Error(`FFmpeg 冒烟测试未通过:\n- ${errors.join('\n- ')}`); + } + + console.log('FFmpeg/FFprobe 冒烟测试通过'); + console.log(`- 输出:${outPath}`); + console.log(`- codec=${codec} size=${actualWidth}x${actualHeight} avgFps=${avgFps} duration=${duration}`); + } finally { + // 清理临时目录(即使失败也尽量清理) + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } + } +}; + +main().catch(err => { + console.error('FFmpeg/FFprobe 冒烟测试失败'); + console.error(String(err?.stack || err)); + process.exitCode = 1; +}); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f82ae6c..49fd2c3 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -9,9 +9,11 @@ import { RxDragHandleDots2 } from "react-icons/rx"; import { FaFolderMinus } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; import { ContentClamp } from "../ui/content-clamp"; +import { useI18n } from "@/i18n"; export function LaunchWindow() { - const { recording, toggleRecording } = useScreenRecorder(); + const { t } = useI18n(); + const { recording, recordingPending, toggleRecording } = useScreenRecorder(); const [recordingStart, setRecordingStart] = useState(null); const [elapsed, setElapsed] = useState(0); @@ -39,7 +41,7 @@ export function LaunchWindow() { const s = (seconds % 60).toString().padStart(2, '0'); return `${m}:${s}`; }; - const [selectedSource, setSelectedSource] = useState("Screen"); + const [selectedSource, setSelectedSource] = useState(""); const [hasSelectedSource, setHasSelectedSource] = useState(false); useEffect(() => { @@ -50,7 +52,7 @@ export function LaunchWindow() { setSelectedSource(source.name); setHasSelectedSource(true); } else { - setSelectedSource("Screen"); + setSelectedSource(""); setHasSelectedSource(false); } } @@ -114,10 +116,12 @@ export function LaunchWindow() { size="sm" className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-left text-xs ${styles.electronNoDrag}`} onClick={openSourceSelector} - disabled={recording} + disabled={recording || recordingPending} > - {selectedSource} + + {hasSelectedSource ? selectedSource : t('Screen')} +
@@ -126,7 +130,7 @@ export function LaunchWindow() { variant="link" size="sm" onClick={hasSelectedSource ? toggleRecording : openSourceSelector} - disabled={!hasSelectedSource && !recording} + disabled={recordingPending || (!hasSelectedSource && !recording)} className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-center text-xs ${styles.electronNoDrag}`} > {recording ? ( @@ -137,7 +141,7 @@ export function LaunchWindow() { ) : ( <> - Record + {t('Record')} )} @@ -151,10 +155,10 @@ export function LaunchWindow() { size="sm" onClick={openVideoFile} className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-right text-xs ${styles.electronNoDrag} ${styles.folderButton}`} - disabled={recording} + disabled={recording || recordingPending} > - Open + {t('Open')} {/* Separator before hide/close buttons */} @@ -163,7 +167,7 @@ export function LaunchWindow() { variant="link" size="icon" className={`ml-2 ${styles.electronNoDrag} hudOverlayButton`} - title="Hide HUD" + title={t('Hide HUD')} onClick={sendHudOverlayHide} > @@ -174,7 +178,7 @@ export function LaunchWindow() { variant="link" size="icon" className={`ml-1 ${styles.electronNoDrag} hudOverlayButton`} - title="Close App" + title={t('Close App')} onClick={sendHudOverlayClose} > diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 61bc2eb..8d0d38e 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -4,16 +4,19 @@ import { MdCheck } from "react-icons/md"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { Card } from "../ui/card"; import styles from "./SourceSelector.module.css"; +import { useI18n } from "@/i18n"; interface DesktopSource { id: string; name: string; + rawName: string; thumbnail: string | null; display_id: string; appIcon: string | null; } export function SourceSelector() { + const { t } = useI18n(); const [sources, setSources] = useState([]); const [selectedSource, setSelectedSource] = useState(null); const [loading, setLoading] = useState(true); @@ -21,25 +24,26 @@ export function SourceSelector() { useEffect(() => { async function fetchSources() { setLoading(true); - try { - const rawSources = await window.electronAPI.getSources({ - types: ['screen', 'window'], - thumbnailSize: { width: 320, height: 180 }, - fetchWindowIcons: true - }); - setSources( - rawSources.map(source => ({ - id: source.id, - name: - source.id.startsWith('window:') && source.name.includes(' — ') - ? source.name.split(' — ')[1] || source.name - : source.name, - thumbnail: source.thumbnail, - display_id: source.display_id, - appIcon: source.appIcon - })) - ); - } catch (error) { + try { + const rawSources = await window.electronAPI.getSources({ + types: ['screen', 'window'], + thumbnailSize: { width: 320, height: 180 }, + fetchWindowIcons: true + }); + setSources( + rawSources.map(source => ({ + id: source.id, + name: + source.id.startsWith('window:') && source.name.includes(' — ') + ? source.name.split(' — ')[1] || source.name + : source.name, + rawName: source.name, + thumbnail: source.thumbnail, + display_id: source.display_id, + appIcon: source.appIcon + })) + ); + } catch (error) { console.error('Error loading sources:', error); } finally { setLoading(false); @@ -61,7 +65,7 @@ export function SourceSelector() {
-

Loading sources...

+

{t('Loading sources...')}

); @@ -72,8 +76,8 @@ export function SourceSelector() {
- Screens - Windows + {t('Screens')} + {t('Windows')}
@@ -134,7 +138,7 @@ export function SourceSelector() { {source.appIcon && ( App icon )} @@ -150,8 +154,8 @@ export function SourceSelector() {
- - + +
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 9dbeaa0..3682e9b 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -3,6 +3,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog" import { X } from "lucide-react" import { cn } from "@/lib/utils" +import { useI18n } from "@/i18n" const Dialog = DialogPrimitive.Root @@ -30,25 +31,28 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)) +>(({ className, children, ...props }, ref) => { + const { t } = useI18n() + return ( + + + + {children} + + + {t("Close")} + + + + ) +}) DialogContent.displayName = DialogPrimitive.Content.displayName const DialogHeader = ({ diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index 0331ca9..cd9b351 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -3,6 +3,7 @@ import { Rnd } from "react-rnd"; import type { AnnotationRegion } from "./types"; import { cn } from "@/lib/utils"; import { getArrowComponent } from "./ArrowSvgs"; +import { useI18n } from "@/i18n"; interface AnnotationOverlayProps { annotation: AnnotationRegion; @@ -27,6 +28,7 @@ export function AnnotationOverlay({ zIndex, isSelectedBoost, }: AnnotationOverlayProps) { + const { t } = useI18n(); const x = (annotation.position.x / 100) * containerWidth; const y = (annotation.position.y / 100) * containerHeight; const width = (annotation.size.width / 100) * containerWidth; @@ -84,7 +86,7 @@ export function AnnotationOverlay({ return ( Annotation @@ -92,7 +94,7 @@ export function AnnotationOverlay({ } return (
- No image + {t('No image')}
); @@ -100,7 +102,7 @@ export function AnnotationOverlay({ if (!annotation.figureData) { return (
- No arrow data + {t('No arrow data')}
); } diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index 2e2999a..35674f0 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -11,6 +11,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Slider } from "@/components/ui/slider"; import { cn } from "@/lib/utils"; import { getArrowComponent } from "./ArrowSvgs"; +import { useI18n } from "@/i18n"; interface AnnotationSettingsPanelProps { annotation: AnnotationRegion; @@ -42,6 +43,7 @@ export function AnnotationSettingsPanel({ onFigureDataChange, onDelete, }: AnnotationSettingsPanelProps) { + const { t } = useI18n(); const fileInputRef = useRef(null); const colorPalette = [ '#FF0000', // Red @@ -73,8 +75,8 @@ export function AnnotationSettingsPanel({ // Validate file type const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; if (!validTypes.includes(file.type)) { - toast.error('Invalid file type', { - description: 'Please upload a JPG, PNG, GIF, or WebP image file.', + toast.error(t('Invalid file type'), { + description: t('Please upload a JPG, PNG, GIF, or WebP image file.'), }); event.target.value = ''; return; @@ -86,13 +88,13 @@ export function AnnotationSettingsPanel({ const dataUrl = e.target?.result as string; if (dataUrl) { onContentChange(dataUrl); - toast.success('Image uploaded successfully!'); + toast.success(t('Image uploaded successfully!')); } }; reader.onerror = () => { - toast.error('Failed to upload image', { - description: 'There was an error reading the file.', + toast.error(t('Failed to upload image'), { + description: t('There was an error reading the file.'), }); }; @@ -104,9 +106,9 @@ export function AnnotationSettingsPanel({
- Annotation Settings + {t('Annotation Settings')} - Active + {t('Active')}
@@ -115,28 +117,28 @@ export function AnnotationSettingsPanel({ - Text + {t('Text')} - Image + {t('Image')} - Arrow + {t('Arrow')} {/* Text Content */}
- +