diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..54b109b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Typecheck (web) + run: | + # next-env.d.ts is gitignored; generate it (and route types) before tsc. + npx next typegen + npx tsc --noEmit + + - name: Typecheck (electron) + run: npm run typecheck:electron + + - name: Build Next.js + run: npm run build + + - name: Build Electron main/preload + run: npm run build:electron diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d611858 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,115 @@ +name: Build and Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + release: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: | + npx next typegen + npx tsc --noEmit + npm run typecheck:electron + + # macOS: decode the App Store Connect API key used for notarization. + - name: Setup Apple API key + if: matrix.os == 'macos-latest' + run: | + mkdir -p "$RUNNER_TEMP/private_keys" + echo "${{ secrets.APPLE_API_KEY }}" | base64 --decode > "$RUNNER_TEMP/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8" + chmod 600 "$RUNNER_TEMP/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8" + + # macOS: build, sign (Developer ID), notarize, and publish to GitHub Releases. + - name: Build, sign & publish (macOS) + if: matrix.os == 'macos-latest' + run: npm run release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ runner.temp }}/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + + # Windows / Linux: build (unsigned) and publish to GitHub Releases. + - name: Build & publish (Windows/Linux) + if: matrix.os != 'macos-latest' + run: npm run release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Flip the draft to published only after every OS build succeeds, so a release + # never goes live (or reaches auto-update) with partial artifacts. + publish: + needs: release + runs-on: ubuntu-latest + env: + AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Resolve previous tag + id: tags + run: | + CURRENT="${{ github.ref_name }}" + PREVIOUS=$(git tag --sort=-v:refname | grep -E '^v[0-9]' | grep -v "^${CURRENT}$" | head -1 || true) + echo "current=$CURRENT" >> "$GITHUB_OUTPUT" + echo "previous=$PREVIOUS" >> "$GITHUB_OUTPUT" + + - name: Generate release notes + if: env.AI_GATEWAY_API_KEY != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ARGS=("${{ steps.tags.outputs.current }}") + if [ -n "${{ steps.tags.outputs.previous }}" ]; then + ARGS+=("${{ steps.tags.outputs.previous }}") + fi + npx tsx scripts/generate-release-notes.ts "${ARGS[@]}" > release-notes.md + gh release edit "${{ github.ref_name }}" \ + --repo wassgha/rescript \ + --notes-file release-notes.md + + - name: Publish the draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release edit "${{ github.ref_name }}" \ + --repo wassgha/rescript \ + --draft=false --latest diff --git a/.gitignore b/.gitignore index 87fd723..984463f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,9 @@ /.next/ /out/ -# production -/build +# electron compile + electron-builder packages +/electron-dist/ +/dist/ # misc .DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5ff4fc7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Rescript contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PLAN.md b/PLAN.md index b63cba2..c7e00c9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -9,21 +9,24 @@ browser, with no server, no auth, and no API calls. ``` ┌────────────────────────────────────────────────────────────────────┐ -│ Next.js (App Router, client-only editor) │ -│ │ -│ ┌───────────────┐ ┌────────────────┐ ┌───────────────────────┐ │ -│ │ TranscriptPanel│ │ MediaPreview │ │ Timeline (canvas) │ │ -│ │ words / cuts │ │ skip playback │ │ waveform + playhead │ │ -│ └───────┬───────┘ └───────┬────────┘ └──────────┬────────────┘ │ -│ └───────────── zustand store ─────────────┘ │ -│ (words, cuts, playback, status) │ -│ │ -│ ┌──────────────────────────┐ ┌───────────────────────────────┐ │ -│ │ Web Worker │ │ ffmpeg.wasm (multi-threaded) │ │ -│ │ transformers.js │ │ - audio extraction (16k PCM) │ │ -│ │ - Whisper (word timing) │ │ - export: trim+concat+encode │ │ -│ │ - pyannote (diarization) │ └───────────────────────────────┘ │ -│ └──────────────────────────┘ │ +│ Electron shell (optional desktop) OR browser / GitHub Pages │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Next.js (App Router, client-only editor) │ │ +│ │ │ │ +│ │ ┌───────────────┐ ┌────────────────┐ ┌──────────────────┐ │ │ +│ │ │ TranscriptPanel│ │ MediaPreview │ │ Timeline (canvas)│ │ │ +│ │ │ words / cuts │ │ skip playback │ │ waveform+playhead│ │ │ +│ │ └───────┬───────┘ └───────┬────────┘ └──────────┬───────┘ │ │ +│ │ └───────────── zustand store ─────────────┘ │ │ +│ │ │ │ +│ │ ┌──────────────────────────┐ ┌──────────────────────────┐ │ │ +│ │ │ Web Worker │ │ ffmpeg.wasm (multi-thr.) │ │ │ +│ │ │ transformers.js │ │ - audio extraction │ │ │ +│ │ │ - Whisper (word timing) │ │ - export trim+concat │ │ │ +│ │ │ - pyannote (diarization) │ └──────────────────────────┘ │ │ +│ │ └──────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ electron/main.ts — app:// static export + auto-updater │ └────────────────────────────────────────────────────────────────────┘ ``` @@ -98,9 +101,11 @@ Everything else is derived: 6. ✅ Export: keep-range concat render to MP4 with progress, download. 7. ✅ Flexible timeline editing: word-boundary drag, Split at playhead (scene boundaries), clip trim handles, manual cuts merged into export. +8. ✅ Electron desktop shell with signed macOS / Windows / Linux releases. ## Future work +- Native macOS SpeechAnalyzer as an optional transcription backend. - Larger Whisper variants + language selection UI; local model import for air-gapped first runs. - Smarter export: stream-copy for keyframe-aligned segments, WebCodecs-based diff --git a/README.md b/README.md index c9ba6b9..ad3e20a 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,15 @@ # Rescript -**Edit video and audio like you edit text — fully offline, in your browser.** +**Edit video and audio like you edit text — fully offline, on your device.** -**✨ Try it now: [wassgha.github.io/rescript](https://wassgha.github.io/rescript/)** +[![License: MIT](https://img.shields.io/badge/license-MIT-111?style=flat-square)](LICENSE) +[![Platforms](https://img.shields.io/badge/platforms-Web%20·%20macOS%20·%20Windows%20·%20Linux-111?style=flat-square)](#download) +[![Electron](https://img.shields.io/badge/Electron-42-111?style=flat-square&logo=electron&logoColor=9FEAF9)](https://www.electronjs.org/) +[![Stars](https://img.shields.io/github/stars/wassgha/rescript?style=flat-square&color=111)](https://github.com/wassgha/rescript/stargazers) +[![Latest release](https://img.shields.io/github/v/release/wassgha/rescript?label=latest%20release&sort=semver&style=flat-square&color=111)](https://github.com/wassgha/rescript/releases/latest) + +**✨ Try it in the browser: [wassgha.github.io/rescript](https://wassgha.github.io/rescript/)** [![Follow @wassgha on X](https://img.shields.io/badge/Follow%20@wassgha-000000?logo=x&logoColor=white)](https://x.com/wassgha) @@ -17,6 +23,18 @@ audio file and it is transcribed locally with per-word timestamps and speaker labels. Delete words in the transcript and the corresponding clip is cut from the media. Export the final cut — without your file ever leaving your device. +## Download + +
+ +Download for macOS — Apple Silicon   Download for macOS — Intel   Download for Windows   Download the AppImage for Linux + +
+ +See the [Releases](https://github.com/wassgha/rescript/releases) page. Desktop +builds auto-update from GitHub Releases. Prefer the browser? Use the +[web app](https://wassgha.github.io/rescript/) — same editor, no install. + - 🔒 **Private by design** — no server, no auth, no uploads; all media processing happens on-device - 📝 **Word-level editing** — select words, press ⌫, the cut follows the text - 📥 **Import your own transcript** — skip Whisper and edit with an SRT, VTT, or JSON caption file @@ -30,14 +48,16 @@ the media. Export the final cut — without your file ever leaving your device. - 🔴 **Cut edges** — drag either edge of a cut to trim independently of Whisper timestamps; double-click to reset - ⚡ **Live preview** — playback skips your cuts in real time -- 📦 **In-browser export** — frame-accurate MP4 (video) or M4A (audio) with ffmpeg.wasm +- 📦 **In-browser / desktop export** — frame-accurate MP4 (video) or M4A (audio) with ffmpeg.wasm - 🎧 **Audio files** — edit podcasts, voice notes, and interviews the same way as video +- 🖥️ **Desktop app** — macOS, Windows, and Linux via Electron (signed + notarized on Mac) ## Stack | Piece | Tech | | --- | --- | | App | [Next.js](https://nextjs.org) + React + TypeScript + Tailwind | +| Desktop | [Electron](https://www.electronjs.org/) + [electron-builder](https://www.electron.build/) (auto-update from GitHub Releases) | | Transcription | [transformers.js](https://github.com/huggingface/transformers.js) running [`whisper-base_timestamped`](https://huggingface.co/onnx-community/whisper-base_timestamped) or [`whisper-small_timestamped`](https://huggingface.co/onnx-community/whisper-small_timestamped) (WebGPU with WASM fallback) in a Web Worker | | Speaker labels | [`pyannote-segmentation-3.0`](https://huggingface.co/onnx-community/pyannote-segmentation-3.0) (ONNX) | | Media processing | [ffmpeg.wasm](https://ffmpegwasm.netlify.app/) (multi-threaded) for audio extraction and export | @@ -47,17 +67,20 @@ the media. Export the final cut — without your file ever leaving your device. ```bash npm install # also copies ffmpeg/onnxruntime WASM into public/vendor -npm run dev # dev server -npm run build # production build +npm run dev # Next.js web app (http://localhost:3000) +npm run electron:dev # Electron shell + Next.js dev server +npm run build # production web build +npm run dist # unsigned desktop installers into dist/ npm run lint # eslint ``` Open [http://localhost:3000](http://localhost:3000) and drop in a video with an -audio track. +audio track. For desktop packaging, signing, and cutting releases, see +[RELEASING.md](./RELEASING.md). > **Note on "offline":** the AI models (Whisper Base ~200 MB, or Small ~600 MB, > plus a small speaker model) are downloaded from the Hugging Face Hub the -> *first* time you transcribe, then cached in browser storage. After that, +> *first* time you transcribe, then cached in browser / app storage. After that, > everything — transcription, editing, export — works with the network fully > disconnected. Your media and transcript never leave the device; the only > third-party request the app makes is anonymous page analytics (Google @@ -78,9 +101,11 @@ audio track. ## Browser support -A Chromium-based browser is recommended. The app requires `SharedArrayBuffer` -(served with COOP/COEP headers) and uses WebGPU for inference when available, -falling back to WASM otherwise. +A Chromium-based browser is recommended for the web app. It requires +`SharedArrayBuffer` (served with COOP/COEP headers) and uses WebGPU for +inference when available, falling back to WASM otherwise. The desktop app +bundles Chromium via Electron and sets the same isolation headers on its +`app://` protocol. ## License diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..2671811 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,89 @@ +# Releasing Rescript + +Releases are built and published automatically by GitHub Actions +(`.github/workflows/release.yml`) whenever you push a `v*.*.*` tag. Each platform +runs on its own runner (macOS / Ubuntu / Windows), and electron-builder uploads +the installers **and** the `latest-*.yml` update manifests to a GitHub Release. +The app's auto-updater (`electron/updater.ts`) reads those manifests straight +from the release — there is no separate update server to maintain. + +The desktop build packages the Next.js **static export** (`out/`) inside Electron. +The GitHub Pages web app continues to deploy from `main` via +`.github/workflows/deploy.yml` and is unaffected by desktop releases. + +## Cutting a release + +From a clean working tree, run one of the cut scripts. Each bumps the version +in `package.json`, makes a `Release vX.Y.Z` commit, creates a matching `vX.Y.Z` +tag, and pushes both — which triggers the release workflow: + +```bash +npm run cut:patch # 0.1.0 -> 0.1.1 +npm run cut:minor # 0.1.0 -> 0.2.0 +npm run cut:major # 0.1.0 -> 1.0.0 +``` + +> These wrap `npm version && git push --follow-tags`. `npm version` +> refuses to run with uncommitted changes, so commit your work first. + +Then watch the build at . +electron-builder uploads installers to a **draft** GitHub Release while the +three platform jobs run. Once they all succeed, the `publish` job writes +AI-generated release notes (from the diff since the previous tag) and flips +the release to **published**. Auto-update only picks up published releases. + +To preview notes for unreleased commits on your machine: + +```bash +npm run notes:preview # needs AI_GATEWAY_API_KEY in .env +``` + +## Required GitHub secrets + +Set these under **Settings → Secrets and variables → Actions**. +A single Developer ID cert + App Store Connect API key works across app IDs. + +| Secret | What it is | +| --- | --- | +| `CSC_LINK` | base64-encoded Developer ID Application `.p12` certificate | +| `CSC_KEY_PASSWORD` | password for that `.p12` | +| `APPLE_API_KEY` | base64-encoded App Store Connect API key (`.p8`) — used for notarization | +| `APPLE_API_KEY_ID` | the API key's 10-character Key ID | +| `APPLE_API_ISSUER` | the API key's Issuer ID (UUID) | +| `AI_GATEWAY_API_KEY` | Vercel AI Gateway key — generates release notes in the publish job (optional; skipped if unset) | + +`GITHUB_TOKEN` is provided automatically by Actions — no setup needed. + +> Windows and Linux builds are currently **unsigned**. To sign Windows later, +> add a code-signing cert and pass `CSC_LINK`/`CSC_KEY_PASSWORD` to the +> Windows job (electron-builder picks them up the same way). + +## How signing & notarization work (macOS) + +- `build.mac` in `package.json` sets `hardenedRuntime: true`, points at + `build/entitlements.mac.plist`, and `notarize: true`. +- electron-builder imports `CSC_LINK` to sign the app, then submits the build + to Apple for notarization using the `APPLE_API_*` credentials and staples + the ticket. +- The entitlements allow JIT/WASM (Whisper + ffmpeg.wasm), network access + (first-run model download from Hugging Face), and user-selected file access. + +## Testing the build locally (unsigned) + +```bash +npm run dist # static-export Next, bundle electron main, build installers into dist/ +``` + +The Electron main/preload process is bundled with esbuild (`scripts/build-electron.mjs`) +so the installer does **not** ship the Next.js / transformers / ffmpeg `node_modules` +tree — those assets already live inside the static `out/` export. Auto-update is +disabled in `npm run electron:dev` and only runs in packaged builds +(`app.isPackaged`). + +## Notes + +- App icons live in `build/` (`icon.png` ≥512px). electron-builder derives + `.icns` / `.ico` from it. +- Desktop builds set `NEXT_PUBLIC_ELECTRON=1` so the static export skips the + COI service worker (headers come from the `app://` protocol). Google Analytics + still loads in the desktop app the same as on the web. diff --git a/app/globals.css b/app/globals.css index 5061b66..9c642f6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -26,6 +26,19 @@ body { background-color: rgb(254 202 202 / 0.7); } +/* + * Electron (macOS): the shell hides the native title bar, so the page supplies + * the window's drag handle instead. Every interactive element inside a + * .app-drag region must opt out with .app-no-drag, or its clicks turn into + * window drags. No-ops in a normal browser tab. + */ +.app-drag { + -webkit-app-region: drag; +} +.app-no-drag { + -webkit-app-region: no-drag; +} + /* Slim scrollbars for panels and the timeline */ .scrollbar-thin { scrollbar-width: thin; @@ -56,6 +69,39 @@ body { animation: tl-split-flash 0.42s ease-out; } +/* Logo loader: bars slide behind the R silhouette that clips them */ +@keyframes logo-bar-slide { + 0%, + 100% { + transform: translateX(-40px); + } + 50% { + transform: translateX(40px); + } +} +/* The slide is symmetric, so `animation-direction: reverse` would look + identical — alternating rows need genuinely mirrored keyframes. */ +@keyframes logo-bar-slide-alt { + 0%, + 100% { + transform: translateX(40px); + } + 50% { + transform: translateX(-40px); + } +} +.logo-bar { + animation: logo-bar-slide 1.5s ease-in-out infinite; +} +.logo-bar-alt { + animation-name: logo-bar-slide-alt; +} +@media (prefers-reduced-motion: reduce) { + .logo-bar { + animation: none; + } +} + .tl-trim-handle:hover > div { transform: scaleX(1.4); } diff --git a/app/layout.tsx b/app/layout.tsx index 207a118..e3f2d56 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -15,10 +15,13 @@ const geistMono = Geist_Mono({ }); const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; +/** Set at desktop static-export time so we skip the COI service worker + * (the Electron `app://` protocol sets COOP/COEP headers directly). */ +const isElectron = /electron/i.test(navigator.userAgent); const title = "Rescript — edit videos like you edit text"; const description = - "A fully offline, open-source transcript-based video editor. Transcribe with Whisper, cut by deleting words, export with ffmpeg — all in your browser."; + "A fully offline, open-source transcript-based video editor. Transcribe with Whisper, cut by deleting words, export with ffmpeg — on your device."; export const metadata: Metadata = { metadataBase: new URL("https://wassgha.github.io"), @@ -60,11 +63,15 @@ export default function RootLayout({ {/* Provides COOP/COEP via a service worker on static hosts (GitHub Pages) that can't send headers; no-op when the server already - sends them. Required for SharedArrayBuffer / multi-threading. */} -