Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
d76672b
feat(website): show the real editor instead of a drawing of it
EtienneLescot Aug 15, 2026
ff9bb28
feat(website): let the reader drive the picture with the scroll wheel
EtienneLescot Aug 15, 2026
cdc9428
fix(website): the scrubbed clip was invisible in production only
EtienneLescot Aug 15, 2026
5084e21
feat(website): reshoot the walkthrough — no stranger's face, no price…
EtienneLescot Aug 16, 2026
8be00ef
feat(website): the editor, redrawn live and walked through by the scroll
EtienneLescot Aug 16, 2026
280e4fd
docs(recreation): write the art direction the build was missing
EtienneLescot Aug 16, 2026
3813023
feat(website): six settings, two acts, one clock
EtienneLescot Aug 17, 2026
9f0e44b
feat(website): build the landing page around the editor
EtienneLescot Aug 17, 2026
2f82bbc
feat(website): ship the app's real cursor packs, for v4
EtienneLescot Aug 18, 2026
84541cb
feat(website): add the webcam clip the composite's bubble plays
EtienneLescot Aug 18, 2026
8f32e4c
feat(website): port the design's v4 recreation
EtienneLescot Aug 18, 2026
e9604e2
fix(website): let the webcam play, instead of animating it
EtienneLescot Aug 18, 2026
ee58952
feat(website): the design's hero
EtienneLescot Aug 18, 2026
ab1bfa8
fix(website): seat the clips on the floor, and close the join
EtienneLescot Aug 18, 2026
9d00c4a
feat(website): draw the four feature panels, as the design has them
EtienneLescot Aug 18, 2026
8711c4a
perf(website): stop shipping pixels and blur nobody sees
EtienneLescot Aug 18, 2026
c972a7a
perf(website): unpack two cursors from the stylesheet, and stop easin…
EtienneLescot Aug 18, 2026
0da018c
fix(website): let the timeline arrive, and the inspector fit what is …
EtienneLescot Aug 18, 2026
ccf0450
fix(website): close the three holes in the score, and cut the page's …
EtienneLescot Aug 18, 2026
a7104a1
fix(website): give the transcript a window, then a script that matche…
EtienneLescot Aug 18, 2026
1ab2890
fix(website): aim the pointer at where the target is, not where it was
EtienneLescot Aug 18, 2026
d3108c8
feat(website): finish the page the recording is recording
EtienneLescot Aug 18, 2026
0ea1328
fix(website): make the rail's scale a function of the stage, not a co…
EtienneLescot Aug 18, 2026
e728d9e
feat(website): the scene, in portrait
EtienneLescot Aug 18, 2026
88314b7
feat(website): give the recorded app its window back
EtienneLescot Aug 18, 2026
05abece
fix(website): put the hand on the handle, and the sweep on the right …
EtienneLescot Aug 18, 2026
ed3d458
fix(website): the captions line was quoting the wrong thirteen
EtienneLescot Aug 18, 2026
6b370f6
fix(website): the hero says who it is, and the recorded pointer points
EtienneLescot Aug 18, 2026
49ec047
fix(website): make the scroll hint a signal, and give it a screen to …
EtienneLescot Aug 18, 2026
daa0075
fix(website): bring the title in with the panel it announces
EtienneLescot Aug 18, 2026
6a45aae
fix(website): stop reserving screen-wide height for a left-column cap…
EtienneLescot Aug 18, 2026
4b0ddaa
fix(website): hang the inspector from the caption again, not from the…
EtienneLescot Aug 18, 2026
e948811
fix(website): make the left column one thing, so it can be aligned wi…
EtienneLescot Aug 18, 2026
cecf0b4
refactor(website): one box for both layouts, instead of two kept in s…
EtienneLescot Aug 18, 2026
1857e21
fix(website): stop the measuring pass flashing a caption on resize
EtienneLescot Aug 18, 2026
a3e3912
fix(website): the palette hangs where the inspector hangs
EtienneLescot Aug 18, 2026
a50b570
fix(website): centre the column on what is visible, not on what is la…
EtienneLescot Aug 18, 2026
a330456
fix(website): hand the column over without a gap, and let portrait si…
EtienneLescot Aug 18, 2026
429fc33
fix(website): let the transcript fill its panel instead of guessing a…
EtienneLescot Aug 18, 2026
d882e21
chore(website): make the branch pass its own lint
EtienneLescot Aug 18, 2026
8b92322
feat(website): aim the recorded pointer at the frame, not at coordinates
EtienneLescot Aug 18, 2026
dd9a06e
fix(website): change the caption with the panel it describes
EtienneLescot Aug 18, 2026
d22ee24
fix(website): give the scene back whole when the driver stops
EtienneLescot Aug 18, 2026
18899a7
perf(website): coalesce the resize measurement into one frame
EtienneLescot Aug 18, 2026
6547bdd
fix(website): read the cursor hotspots the way the app reads them
EtienneLescot Aug 18, 2026
e1c39b6
feat(website): end the cursor take on the green pointer
EtienneLescot Aug 18, 2026
ed1ad57
fix(website): make two build-time checks fail loudly instead of quietly
EtienneLescot Aug 18, 2026
892e707
chore(website): keep a local account name out of the vendored document
EtienneLescot Aug 18, 2026
0a107f6
docs(website): correct the counts, the gate width and the claims that…
EtienneLescot Aug 18, 2026
f510426
fix(website): a 404 in the structured data, a literal colour, a locke…
EtienneLescot Aug 18, 2026
d0e91ec
build(website): let the generator own generated.ts, and make --check …
EtienneLescot Aug 18, 2026
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
17 changes: 17 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,23 @@ jobs:
- name: Type-check
working-directory: website
run: npm run typecheck
# The walkthrough's clips and stills are committed to this repo, which has
# no LFS filter — their size is permanent, so a budget that lives only in a
# design note drifts on the first re-cut. This also fails any clip encoded
# with an audio track, which iOS Safari refuses to autoplay without a
# gesture even when the element is muted.
- name: Check media budget
working-directory: website
run: npm run check:media
# The recreation's data is generated from the app's own source and a
# vendored project document. It is committed, so it can go stale in either
# direction — a locale key moves, a timeline constant changes — and the
# page would then be showing numbers the application no longer produces.
# The generator compares its own output byte for byte, which is why the
# formatter is told to leave that one file alone (biome.json).
- name: Check recreation data
working-directory: website
run: npm run check:recreation
- name: Build
working-directory: website
run: npm run build
Expand Down
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
},
"formatter": {
"enabled": true,
"includes": ["**", "!website/src/components/Recreation/generated.ts"],
"indentStyle": "tab",
"formatWithErrors": true,
"lineEnding": "lf",
Expand Down
4 changes: 2 additions & 2 deletions website/docs/ai-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ keywords:

# AI editing

OpenScreen ships an optional agent that edits your project from a chat panel. It is **off until you connect a provider yourself**, and it is the only part of the app that talks to a network.
OpenScreen ships an optional agent that edits your project from a chat panel. It is **off until you connect a provider yourself**. Apart from the one-time Whisper model download, the provider you connect is the only network OpenScreen uses — for this agent, and for [caption translation](./captions.md#translation).

:::tip
None of this is required. Recording, editing, transcription, captions, and export all work with zero network access and no account, whether or not you ever open the chat panel.
None of this is required. Recording, editing, transcription, captions, and export all work with no account and no provider, whether or not you ever open the chat panel — the only network any of them touches is the [one-time Whisper model download](./captions.md#transcribing) on your first transcription.
:::

## Connecting a provider
Expand Down
6 changes: 3 additions & 3 deletions website/docs/captions.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ keywords:

# Captions & transcript

OpenScreen transcribes your recording's audio **entirely on-device** — nothing is uploaded, and it works offline. That one transcript is then the source for two things: the captions burned into your video, and a text view you can edit your recording from.
OpenScreen transcribes your recording's audio **entirely on-device** — your audio is never uploaded, and once the model is on disk it works offline. That one transcript is then the source for two things: the captions burned into your video, and a text view you can edit your recording from.

## Transcribing

Expand All @@ -23,7 +23,7 @@ Every clip carries its own transcript. Run it either way:
- From the **Media** stage — select an asset card and hit **Regenerate**. This is also where you force a language (Auto, English, French, Spanish) instead of letting Whisper detect it, and where per-asset status lives (Pending, Transcribing, Generated, Failed).
- From the **Captions** facet in the editor's inspector — **Transcribe video** runs the same pipeline on the current media.

The first run downloads a local Whisper model (~264 MB, SHA-256 verified, written atomically so a half-download can never be picked up). After that, transcription is fully offline. It runs natively — whisper.cpp with a GPU backend picked at runtime: Metal on Apple Silicon, Vulkan on Windows and Linux, CPU everywhere else.
The whisper.cpp engine ships inside the app; the model does not. The first run downloads it from huggingface.co (~264 MB, SHA-256 verified, written atomically so a half-download can never be picked up) — the one moment transcription needs a network. After that it is fully offline, on a GPU backend picked at runtime: Metal on Apple Silicon, Vulkan on Windows and Linux, CPU everywhere else.

Word timings come from Whisper's own DTW token timestamps, then get re-anchored on the audio itself — every boundary is pulled back to the quietest moment just before it. This is what makes a transcript-driven cut land where the word actually starts instead of a syllable late.

Expand All @@ -42,7 +42,7 @@ Open the **Captions** facet in the inspector:
| **Position** | Top / Middle / Bottom, left / center / right alignment, a fine vertical offset, and band width as a % of the frame. |
| **Line length** | Min and max words per line (1–12). Lines are packed inside that range. |

Size is expressed in pixels at a 1080-high frame and scales with the real output, so captions look the same at 720p, 1080p, or source. Preview and export share the same layout code — what you see is what gets burned in.
Size is expressed in pixels at a 1080-high frame and scales with the real output, so captions look the same at 720p, 1080p, or source. Preview and export share the same layout code — what you see is what gets burned in. Burned in is the only form they take: OpenScreen writes no sidecar `.srt` or `.vtt`, so captions can't be turned off by whoever watches the file.

### Translation

Expand Down
6 changes: 3 additions & 3 deletions website/docs/export.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ If something fails during render or write, the dialog shows the error so you can

## How MP4 is rendered

MP4 export runs through the same native Rust + Direct3D 11 compositor that draws the live preview, one clip at a time, on a single GPU device: demux → decode → composite → hardware encode → mux, with no CPU readback in between. The preview pauses itself for the duration so the two aren't fighting over the GPU.
MP4 export runs through the same native Rust compositor that draws the live preview — Direct3D 11 on Windows, Metal on macOS, wgpu/WGSL on Linux — one clip at a time, on a single GPU device: demux → decode → composite → encode → mux. On Windows and macOS the encoder takes the composed frame straight off the GPU, with no CPU readback in between; on Linux the frame is read back and encoded in software. The preview pauses itself for the duration so the two aren't fighting over the GPU.

Because preview and export consume the same scene description, the frame you're looking at is the frame you get — there is no separate export renderer that could drift.

:::warning Platform support
The native compositor is **Windows-only today**, so MP4 export currently requires Windows. GIF export has its own renderer-side path and works on all three platforms. See the [roadmap](https://github.com/getopenscreen/openscreen/blob/main/ROADMAP.md) for status.
:::note Platform support
MP4 and GIF export both work on Windows, macOS, and Linux. The one difference left is speed: the Linux encode is software rather than hardware today, so the same export takes longer there. See the [roadmap](https://github.com/getopenscreen/openscreen/blob/main/ROADMAP.md) for status.
:::

## Exported file vs. project file
Expand Down
8 changes: 4 additions & 4 deletions website/docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,20 +100,20 @@ You may need to grant screen-recording permission depending on your desktop envi

## Platform differences

The editing tools are the same everywhere — zooms, backgrounds, crop/trim/speed, annotations, transcription, captions, and projects. **Capture** and **MP4 export** differ:
The editing tools are the same everywhere — zooms, backgrounds, crop/trim/speed, annotations, transcription, captions, and projects. Every export format works on every platform; what differs is **capture**, and how fast MP4 encodes on Linux:

| | macOS | Windows | Linux |
|---|---|---|---|
| Capture pipeline | Native (ScreenCaptureKit) | Native (Windows Graphics Capture) | Browser pipeline |
| Custom cursor themes / click effects | ✅ | ✅ | ❌ (position-only, used for auto-zoom) |
| Webcam | Native capture | Native capture | Browser capture (still works as PiP) |
| System audio | macOS 13+; permission prompt on 14.2+; not available on macOS 12 and below | Works out of the box | Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+) |
| MP4 export | ❌ not yet | ✅ | ❌ not yet |
| MP4 export | | ✅ | ✅ (software encode) |
| GIF export | ✅ | ✅ | ✅ |
| On-device transcription | Metal (Apple Silicon) / CPU | Vulkan / CPU | Vulkan / CPU |

:::warning MP4 export is Windows-only for now
The GPU compositor behind the live preview and MP4 export is built on Direct3D 11 and currently ships only in the Windows build. Recording, editing, and GIF export work on all three platforms; MP4 export does not yet. Track it on the [roadmap](https://github.com/getopenscreen/openscreen/blob/main/ROADMAP.md).
:::note MP4 export on Linux
The GPU compositor behind the live preview and MP4 export has three backends — Direct3D 11 on Windows, Metal on macOS, wgpu/WGSL on Linux — and ships in all three builds. The Linux one encodes in software rather than on the GPU, so an export there takes longer than the same one on Windows or macOS; hardware encode is tracked on the [roadmap](https://github.com/getopenscreen/openscreen/blob/main/ROADMAP.md).
:::

Next: [Quick start](./quick-start.md) walks through your first recording.
4 changes: 2 additions & 2 deletions website/docs/intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ keywords:

# Welcome to OpenScreen

OpenScreen is a **free, open-source screen recorder and editor**. It uses native capture APIs (ScreenCaptureKit on macOS, Windows Graphics Capture on Windows) for low-overhead recording, and composites both the live preview and the final export on the GPU through a native Rust + Direct3D 11 renderer — one path, so what you see in the editor is what comes out of the export.
OpenScreen is a **free, open-source screen recorder and editor**. It uses native capture APIs (ScreenCaptureKit on macOS, Windows Graphics Capture on Windows) for low-overhead recording, and composites both the live preview and the final export on the GPU through a native Rust renderer (Direct3D 11 on Windows, Metal on macOS, wgpu on Linux) — one path, so what you see in the editor is what comes out of the export.

:::warning
OpenScreen is **not production-grade**. The project is in active development and rough edges are expected.
Expand All @@ -31,7 +31,7 @@ OpenScreen is **not production-grade**. The project is in active development and
- [Export](./export.md) to MP4 (720p/1080p/source, H.264 or H.265) or animated GIF.

:::note
Recording, editing, transcription, captions, and export all work fully offline with no account. AI chat editing and caption translation are the only features that talk to a network and only once you connect a provider yourself.
Recording, editing, transcription, captions, and export all work offline with no account. The one exception is the first transcription you ever run, which downloads its Whisper model (~264 MB) once — after that, transcription is offline too. AI chat editing and caption translation are the only features that keep talking to a network, and only once you connect a provider yourself.
:::

## Project facts
Expand Down
4 changes: 3 additions & 1 deletion website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
"build": "docusaurus build",
"serve": "docusaurus serve",
"clear": "docusaurus clear",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"check:media": "node scripts/check-media-budget.mjs",
"check:recreation": "node scripts/gen-recreation.mjs --check"
},
"dependencies": {
"@docusaurus/core": "^3.9.2",
Expand Down
182 changes: 182 additions & 0 deletions website/scripts/check-media-budget.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env node
/**
* Guards the landing page's media budget.
*
* The walkthrough's page weight is the whole argument for shipping video at all
* — a demo that makes the page slow has argued against the product it is
* demonstrating. Byte budgets that live only in a design document drift on the
* first re-cut, so they live here and the build fails on them.
*
* Everything under website/static/ is committed to git permanently: this repo
* has no LFS filter, and video neither compresses nor deltas, so a re-cut costs
* its full size again rather than a diff. The directory ceiling is the real
* constraint; the per-file ones just localise the failure.
*
* The audio check is not a nicety. WebKit grants gesture-free autoplay to media
* that contains no audio track — the `muted` attribute alone is not enough on
* iOS — and the masters these clips are cut from carry an AAC stream of digital
* silence. Encoding without `-an` produces a clip that silently refuses to play
* for a large share of visitors, and looks perfect on the machine that made it.
*/

import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";

const ROOT = resolve(import.meta.dirname, "..");

const RULES = [
{ dir: "static/video", ext: [".mp4"], max: 200_000 },
{ dir: "static/img/walkthrough", ext: [".jpg", ".avif"], max: 70_000 },
// The cursor packs, copied out of the application's own public/cursors. Each
// is a 32-logical sprite at 128px, so a five-figure file here means someone
// has shipped a full-resolution source by mistake.
{ dir: "static/img/cursors", ext: [".png"], max: 12_000 },
];

/**
* A `-scrub` clip is a different kind of file and gets a different ceiling.
*
* It is never played; scroll position seeks it. A seek into a long GOP has to
* decode from the preceding keyframe, so these are encoded all-intra — every
* frame its own keyframe — which is the entire reason they are large. Measured
* on the export beat at 960x540: 32 KB at `-g 60`, 402 KB all-intra at 30fps,
* 268 KB once dropped to 20fps. Twenty frames a second is finer than a scroll
* resolves, and it is where this ceiling was set.
*
* The generous per-file number is safe because TOTAL_MAX is the rule that
* actually protects the repository, and it did not move.
*/
const SCRUB_MAX = 300_000;
const TOTAL_MAX = 1_600_000;

const problems = [];
let total = 0;
let counted = 0;

function walk(dir) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return []; // an absent directory is a section not yet shot, not a failure
}
return entries.flatMap((e) => (e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)]));
}

/** The boxes a track's `hdlr` can be nested inside. Everything else is skipped
* whole — including `mdat`, which is the point. */
const MP4_CONTAINERS = new Set(["moov", "trak", "mdia"]);

/**
* True if the MP4 declares a sound track. Reads the `hdlr` boxes rather than
* shelling out to ffprobe, which is not guaranteed on a CI runner and would make
* this check quietly skippable — which is how the guarantee would be lost.
*
* Walked as a box tree, not searched for as a byte pattern. `mdat` is compressed
* picture: the twelve bytes that spell a sound handler can occur in it by
* chance, and the failure that would produce — a silent clip rejected for an
* audio track it does not have — is unreproducible and looks like a bug in this
* file rather than in the clip.
*/
function hasAudioTrack(file) {
const buf = readFileSync(file);
const walk = (start, end) => {
let pos = start;
while (pos + 8 <= end) {
let size = buf.readUInt32BE(pos);
let head = 8;
if (size === 1) {
if (pos + 16 > end) return false;
size = Number(buf.readBigUInt64BE(pos + 8));
head = 16;
} else if (size === 0) {
size = end - pos; // the last box, extending to the end of the file
}
// A size that runs past its parent means the file is not what it says
// it is; stop rather than resync, which is how a scan gets back into
// payload bytes.
if (size < head || pos + size > end) return false;
const type = buf.toString("latin1", pos + 4, pos + 8);
if (type === "hdlr") {
// FullBox: 4 version/flags, 4 pre_defined, then handler_type.
if (buf.toString("latin1", pos + head + 8, pos + head + 12) === "soun") return true;
} else if (MP4_CONTAINERS.has(type) && walk(pos + head, pos + size)) {
return true;
}
pos += size;
}
return false;
};
return walk(0, buf.length);
}
Comment thread
EtienneLescot marked this conversation as resolved.

/**
* Why an AVIF might not belong. It is offered *ahead of* the JPEG and never
* instead of it — an engine that cannot decode AVIF falls through to the same
* <picture>'s JPEG, and the schema.org screenshot points at the JPEG as well —
* so the pair only pays for itself while the AVIF is the smaller of the two. An
* encoder run that came out heavier would hand every modern browser the worse
* file, and the JPEG would still be in git behind it: two costs for no win.
*/
function avifProblem(file, bytes) {
const fallback = file.replace(/\.avif$/, ".jpg");
if (!existsSync(fallback)) {
return "has no .jpg beside it — engines without AVIF would have nothing to fall back to";
}
const fallbackBytes = statSync(fallback).size;
if (bytes >= fallbackBytes) {
return (
`${bytes.toLocaleString()} B is no smaller than its ${fallbackBytes.toLocaleString()} B ` +
`JPEG. Re-encode it lower, or drop the AVIF and ship the JPEG alone.`
);
}
return null;
}

for (const rule of RULES) {
const abs = join(ROOT, rule.dir);
for (const file of walk(abs)) {
const rel = relative(ROOT, file);
const bytes = statSync(file).size;
total += bytes;
counted += 1;

if (!rule.ext.some((e) => file.endsWith(e))) {
problems.push(
`${rel}: unexpected file type in ${rule.dir} (only ${rule.ext.join(" or ")} belongs here)`,
);
continue;
}
const ceiling = /-scrub(-sm)?\.mp4$/.test(file) ? SCRUB_MAX : rule.max;
if (bytes > ceiling) {
problems.push(
`${rel}: ${bytes.toLocaleString()} B exceeds the ${ceiling.toLocaleString()} B ceiling`,
);
}
const avifFault = file.endsWith(".avif") && avifProblem(file, bytes);
if (avifFault) problems.push(`${rel}: ${avifFault}`);
if (file.endsWith(".mp4") && hasAudioTrack(file)) {
problems.push(
`${rel}: carries an audio track. Re-encode with -an — iOS Safari refuses ` +
`gesture-free autoplay for media that has one, muted or not.`,
);
}
}
}

if (total > TOTAL_MAX) {
problems.push(
`walkthrough media totals ${total.toLocaleString()} B, over the ` +
`${TOTAL_MAX.toLocaleString()} B ceiling. These bytes are permanent in git history.`,
);
}

const summary = `${counted} file${counted === 1 ? "" : "s"}, ${total.toLocaleString()} B of ${TOTAL_MAX.toLocaleString()}`;

if (problems.length) {
console.error(`media budget FAILED — ${summary}`);
for (const p of problems) console.error(` · ${p}`);
process.exit(1);
}

console.log(`media budget ok — ${summary}`);
Loading
Loading