Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
name: Deploy to GitHub Pages
name: Deploy GitHub Pages redirect

# The web app itself is deployed to app.getrescript.com by Vercel (see
# vercel.json), which can serve the cross-origin isolation headers GitHub Pages
# cannot. This workflow keeps the old wassgha.github.io/rescript URL alive as a
# redirect so existing links, README badges, and shared URLs still land in the
# editor.

on:
push:
branches: [main]
paths:
- "redirect/**"
- ".github/workflows/deploy.yml"
workflow_dispatch:

permissions:
Expand All @@ -19,22 +28,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: actions/configure-pages@v5
with:
enablement: true
- run: npm ci
- name: Build static export
run: npm run build
env:
STATIC_EXPORT: "1"
NEXT_PUBLIC_BASE_PATH: /${{ github.event.repository.name }}
- name: Assemble redirect site
run: |
mkdir -p site
cp redirect/index.html site/index.html
# Deep links (/rescript/editor) have no file on Pages, so 404.html is
# served for those — it runs the same path-preserving redirect.
cp redirect/index.html site/404.html
touch site/.nojekyll
- uses: actions/upload-pages-artifact@v3
with:
path: out
path: site

deploy:
needs: build
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,3 @@ next-env.d.ts

# runtime assets copied from node_modules on postinstall
/public/vendor/
/public/coi-serviceworker.js
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
[![Join the Discord](https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white)](https://discord.gg/qJAhYFydat)
[![Follow @wassgha on X](https://img.shields.io/badge/Follow%20@wassgha-000000?logo=x&logoColor=white)](https://x.com/wassgha)

**✨ Try it in the browser [wassgha.github.io/rescript](https://wassgha.github.io/rescript/)** or download the [Desktop App](#download)
**✨ Try it in the browser [app.getrescript.com](https://app.getrescript.com/)** or download the [Desktop App](#download)

[![Rescript Demo](./screenshots/rescript.png)](https://wassgha.github.io/rescript/)
[![Rescript Demo](./screenshots/rescript.png)](https://app.getrescript.com/)

Rescript is an open-source, transcript-based media editor. Drop in a video or
audio file and it is transcribed locally with per-word timestamps and speaker
Expand All @@ -26,9 +26,9 @@ the media. Export the final cut — without your file ever leaving your device.

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.
[web app](https://app.getrescript.com/) — same editor, no install.

- 🔒 **Private by design** — no server, no auth, no uploads; all media processing happens on-device
- 🔒 **Private by design** — 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
- 📤 **Export hub** — video (MP4/WebM, 720p–4K), audio (M4A/MP3/WAV), transcript (TXT/MD), or subtitles (SRT/VTT/JSON)
Expand Down Expand Up @@ -78,12 +78,13 @@ audio track. For desktop packaging, signing, and cutting releases, see
> *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
> Analytics), which fails silently when offline.
> third-party requests the app makes are anonymous page analytics (Google
> Analytics and Vercel Web Analytics — page views and app opens, nothing about
> your media), which fail silently when offline.

## How it works

[![Rescript Promo](./screenshots/rescript-2.png)](https://wassgha.github.io/rescript/)
[![Rescript Promo](./screenshots/rescript-2.png)](https://app.getrescript.com/)

1. **Extract** — ffmpeg.wasm decodes the audio track to mono 16 kHz PCM.
2. **Transcribe** — Whisper runs in a Web Worker with `return_timestamps: "word"`,
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ User-facing progress for Rescript. Items are marked done when they shipped to
## Done

### Core editor
- [x] Offline transcript-based editor in the browser (no server, no auth, no uploads)
- [x] Offline transcript-based editor in the browser (no auth, no uploads)
- [x] Upload → local Whisper transcription with per-word timestamps
- [x] Speaker diarization (pyannote) with speaker-grouped transcript
- [x] Cut / restore by selecting words (⌫, floating toolbar); undo / redo
Expand Down
33 changes: 10 additions & 23 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import { GoogleAnalytics } from "@next/third-parties/google";
import { Analytics } from "@vercel/analytics/next";
import { isElectron } from "@/lib/platform";
import "./globals.css";

const geistSans = Geist({
Expand All @@ -16,13 +15,12 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});

const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";

// Vercel Web Analytics is collected by the deployment at getrescript.com.
// Neither GitHub Pages nor the Electron app:// shell can serve the default
// /_vercel/insights paths, so point the SDK at absolute URLs there. Left unset
// in development so the SDK loads its debug script and logs to the console
// instead of reporting local traffic.
// Vercel Web Analytics for both the web app and the desktop shell is collected
// by the deployment at getrescript.com, so the two show up in one dashboard.
// The Electron app:// shell cannot serve the default /_vercel/insights paths at
// all, so the SDK is pointed at absolute URLs through that project's CORS
// proxy. Left unset in development so the SDK loads its debug script and logs
// to the console instead of reporting local traffic.
const analyticsHost =
process.env.NEXT_PUBLIC_VERCEL_ANALYTICS_HOST ?? "https://www.getrescript.com";
const analyticsProps =
Expand All @@ -38,18 +36,18 @@ const description =
"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"),
metadataBase: new URL("https://app.getrescript.com"),
title,
description,
openGraph: {
type: "website",
siteName: "Rescript",
url: `${basePath}/`,
url: "/",
title,
description,
images: [
{
url: `${basePath}/og.png`,
url: "/og.png",
width: 1200,
height: 630,
alt: "Rescript — a transcript-based video editor running in the browser",
Expand All @@ -60,7 +58,7 @@ export const metadata: Metadata = {
card: "summary_large_image",
title,
description,
images: [`${basePath}/og.png`],
images: ["/og.png"],
},
};

Expand All @@ -84,17 +82,6 @@ export default function RootLayout({
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: appearanceBootScript }}
/>
{/* 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.
Skipped in the Electron shell — the app:// protocol sets the
headers directly. */}
{!isElectron && (
<Script
src={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/coi-serviceworker.js`}
strategy="beforeInteractive"
/>
)}
</head>
<body className="min-h-full">
{children}
Expand Down
5 changes: 2 additions & 3 deletions components/UploadScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,8 @@ export default function UploadScreen({
const [dragging, setDragging] = useState(false);
const [projects, setProjects] = useState<ProjectMeta[]>([]);
const [busyId, setBusyId] = useState<string | null>(null);
// The pipeline needs SharedArrayBuffer, which on static hosts only appears
// after the COI service worker reloads the page. Accepting a file before then
// would fail immediately and lose the file to that reload.
// The pipeline needs SharedArrayBuffer, so don't accept a file until the page
// is confirmed cross-origin isolated — transcription would fail immediately.
const isolation = useCrossOriginIsolated();
const ready = isolation === "ready";
const source = useEditorStore((s) => s.source);
Expand Down
1 change: 0 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ const eslintConfig = defineConfig([
"dist/**",
// Runtime assets copied from node_modules on postinstall.
"public/vendor/**",
"public/coi-serviceworker.js",
]),
]);

Expand Down
46 changes: 10 additions & 36 deletions hooks/useCrossOriginIsolated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@

import { useSyncExternalStore } from "react";

export type IsolationState = "checking" | "preparing" | "ready" | "unavailable";

/** How long to wait for the COI service worker to take over before giving up. */
const PREPARE_TIMEOUT_MS = 10_000;
export type IsolationState = "checking" | "ready" | "unavailable";

function isIsolated() {
return self.crossOriginIsolated && typeof SharedArrayBuffer !== "undefined";
Expand All @@ -23,37 +20,14 @@ function emit(next: IsolationState) {
for (const listener of listeners) listener();
}

function watch() {
if (isIsolated()) return emit("ready");
// A service worker already controlling an un-isolated page means the worker
// cannot fix this (insecure context, stripped headers, or a browser without
// SharedArrayBuffer) — waiting for a reload that never comes is worse than
// saying so.
const sw = navigator.serviceWorker;
if (!window.isSecureContext || !sw || sw.controller) return emit("unavailable");

emit("preparing");
// coi-serviceworker reloads as soon as it takes control, so normally the page
// goes away before this resolves. Poll anyway — the reload can be blocked (or
// skipped by a future version) and we still want to notice isolation.
let waited = 0;
const timer = setInterval(() => {
waited += 250;
if (isIsolated()) {
clearInterval(timer);
emit("ready");
} else if (waited >= PREPARE_TIMEOUT_MS) {
clearInterval(timer);
emit("unavailable");
}
}, 250);
}

function subscribe(listener: () => void) {
listeners.add(listener);
if (!watching) {
watching = true;
watch();
// Isolation is decided by the response headers of the document itself, so it
// is already settled by the time any component mounts — there is nothing to
// poll for or wait on.
emit(isIsolated() ? "ready" : "unavailable");
}
return () => listeners.delete(listener);
}
Expand All @@ -62,11 +36,11 @@ function subscribe(listener: () => void) {
* Reports whether the page can use SharedArrayBuffer, which ffmpeg.wasm and
* onnxruntime both require for multi-threading.
*
* On static hosts isolation comes from public/coi-serviceworker.js, which
* registers itself and then reloads the page — so on a first visit the app is
* briefly interactive *without* isolation. Anything started in that window dies
* with "SharedArrayBuffer is not defined" (and loses its work to the reload),
* so the UI waits for "ready" before touching the pipeline.
* Isolation comes from real COOP/COEP headers on every target: vercel.json for
* the web app, the app:// protocol handler for Electron, and next.config.ts
* headers() for `next dev`. "unavailable" therefore means the browser itself
* can't do it (insecure context, or no SharedArrayBuffer), not that we're still
* setting up — so the UI can say so immediately instead of showing a spinner.
*/
export function useCrossOriginIsolated(): IsolationState {
return useSyncExternalStore(
Expand Down
10 changes: 4 additions & 6 deletions lib/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
import type { FFmpeg } from "@ffmpeg/ffmpeg";
import type { TimeRange } from "./types";

const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const CORE_BASE = `${BASE_PATH}/vendor/ffmpeg`;
const CORE_BASE = "/vendor/ffmpeg";
const INPUT_NAME = "input_video";

let ffmpegPromise: Promise<FFmpeg> | null = null;
Expand All @@ -19,8 +18,8 @@ export async function getFFmpeg(): Promise<FFmpeg> {
import("@ffmpeg/util"),
]);
// Multi-threaded ffmpeg.wasm needs SharedArrayBuffer, i.e. a
// cross-origin-isolated page (real COOP/COEP headers, or the COI service
// worker after its reload). Without it the core throws a bare
// cross-origin-isolated page (COOP/COEP from vercel.json on the web, from
// the app:// handler in Electron). Without it the core throws a bare
// "SharedArrayBuffer is not defined" from deep inside the worker.
if (!self.crossOriginIsolated || typeof SharedArrayBuffer === "undefined") {
throw new Error(
Expand All @@ -34,8 +33,7 @@ export async function getFFmpeg(): Promise<FFmpeg> {
workerURL: await toBlobURL(`${CORE_BASE}/ffmpeg-core.worker.js`, "text/javascript"),
// Served same-origin (copied on postinstall): the bundled class worker
// contains a dynamic import() that Next's bundler cannot handle.
classWorkerURL: new URL(`${BASE_PATH}/vendor/ffmpeg-class/worker.js`, location.href)
.href,
classWorkerURL: new URL("/vendor/ffmpeg-class/worker.js", location.href).href,
});
return ffmpeg;
})();
Expand Down
11 changes: 5 additions & 6 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { NextConfig } from "next";

// Static export for GitHub Pages: set STATIC_EXPORT=1 and (for project pages
// served under /<repo>/) NEXT_PUBLIC_BASE_PATH=/<repo>. Static hosts cannot
// send custom headers, so cross-origin isolation is provided at runtime by
// public/coi-serviceworker.js instead of the headers() below.
// STATIC_EXPORT=1 emits the static bundle shipped to both targets: the web app
// at app.getrescript.com (served by Vercel, which sends the cross-origin
// isolation headers from vercel.json) and the Electron shell (which sets them
// itself in electron/main.ts). Both serve from the root, so there is no
// basePath. The headers() below only covers `next dev`, where neither applies.
const isExport = process.env.STATIC_EXPORT === "1";
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";

const nextConfig: NextConfig = {
reactStrictMode: true,
Expand All @@ -14,7 +14,6 @@ const nextConfig: NextConfig = {
...(isExport
? {
output: "export" as const,
basePath,
images: { unoptimized: true },
}
: {
Expand Down
7 changes: 0 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"build:electron": "node scripts/build-electron.mjs",
"typecheck:electron": "tsc -p electron/tsconfig.json --noEmit",
"electron:dev": "npm run build:electron && concurrently -k -n next,electron -c cyan,magenta \"next dev\" \"node scripts/electron-dev.mjs\"",
"export:web": "cross-env STATIC_EXPORT=1 next build",
"export:desktop": "cross-env STATIC_EXPORT=1 NEXT_PUBLIC_ELECTRON=1 next build",
"dist": "npm run export:desktop && npm run build:electron && electron-builder --config.npmRebuild=false",
"release": "npm run export:desktop && npm run build:electron && electron-builder --publish always --config.npmRebuild=false",
Expand All @@ -39,7 +40,6 @@
"@huggingface/transformers": "^4.2.0",
"@next/third-parties": "^16.2.12",
"@vercel/analytics": "^2.0.1",
"coi-serviceworker": "^0.1.7",
"lucide-react": "^1.27.0",
"next": "16.2.12",
"parakeet.js": "^1.4.4",
Expand Down
1 change: 0 additions & 1 deletion public/.nojekyll

This file was deleted.

Loading
Loading