Vibecode Sendspark
track this build4 phases, 12 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Sendspark, record lightweight personalized videos and generate recipient-specific landing pages. The hard boundary is personalization at scale, crm workflows, hosting, and engagement tracking, plus capture polish, hosting, and collaboration.
You are building a lean indie version of Sendspark. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Sendspark · indie build A local screen and camera recorder whose hard problems are proving you captured the audio you think you captured, producing a file that seeks instead of one the browser refuses to scrub, and building a recipient page that cannot be found by guessing. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Tauri 2 with React and TypeScript, one desktop application | the capture permissions and the file handling both want to be native | | Capture | The display and media capture APIs inside the webview, with tracks verified after the fact | what you asked for and what the operating system gave you are frequently different | | Encoding | ffmpeg invoked locally for remux and trim, originals kept | browser recorder output needs a pass before it behaves like a video file | | Storage | SQLite plus a videos directory, no upload anywhere | a personal video to one person is not something to put on someone else's disk by default | | Sharing | A static page folder with a random identifier, no tracking of any kind | an unguessable link is the honest version of access control here | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Rust toolchain and the Tauri 2 prerequisites** · free - Why: Builds the desktop application and the native side that shells out to ffmpeg. - Get it: rustup, then your platform's Tauri prerequisites. - Verify: A Tauri template builds and launches on your machine - [ ] **Node.js 22 or newer** · free - Why: Builds the front end. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **ffmpeg and ffprobe available to the application** · free - Why: The remux pass is what makes the recording a file that seeks, and ffprobe is how the build verifies it. - Get it: Your package manager, or bundle a static build with the application. - Verify: ffprobe reports a duration for a remuxed test file - [ ] **Screen recording and microphone permission on your operating system** · free - Why: Both are granted outside your application and both fail in ways that look like a bug in your code, usually on the first run after a system update. - Get it: Grant them in the system settings for your build, not only for your development runner. - Verify: A capture from the packaged build returns both a video and an audio track - [ ] **How recipient pages are served and for how long** · free to a few pounds a month - Why: A page nobody can reach is not shared, and a page that never expires is a personal video sitting on a disk for years, so this is decided before the page generator exists. - Get it: Decide where the folders are served from, the default expiry, and the identifier length. - Verify: All three appear in the configuration and in the README ## Quick start ```sh ffmpeg -i raw.webm -c copy -fflags +genpts fixed.webm ffprobe -v error -show_entries format=duration fixed.webm ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Hosted sharing on a public domain. - Viewer identity and engagement tracking. - Bulk personalised video from a spreadsheet. - Team libraries, seats and workspaces. - personalization at scale, CRM workflows, hosting, and engagement tracking - instant hosted sharing - viewer analytics - team libraries - cross-device capture If one of those is essential to you, that is the reason to keep paying for Sendspark, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Sendspark The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a desktop recorder that makes a short personal video and a small page for one recipient, both stored on your own machine to replace Sendspark. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Tauri 2 with React and TypeScript, one desktop application · the capture permissions and the file handling both want to be native - Capture: The display and media capture APIs inside the webview, with tracks verified after the fact · what you asked for and what the operating system gave you are frequently different - Encoding: ffmpeg invoked locally for remux and trim, originals kept · browser recorder output needs a pass before it behaves like a video file - Storage: SQLite plus a videos directory, no upload anywhere · a personal video to one person is not something to put on someone else's disk by default - Sharing: A static page folder with a random identifier, no tracking of any kind · an unguessable link is the honest version of access control here ### Data model (create this before Phase 1) - recordings(id, title, created_at, source_kind, duration_ms, width, height, has_audio, audio_source, raw_path, remuxed_path, sha256, bytes) - chunks(recording_id, ordinal, path, bytes, written_at) · written to disk as they arrive so a crash keeps what was recorded - trims(id, recording_id, start_ms, end_ms, created_at, output_path, reencoded) · non-destructive, the original is never replaced - pages(id, recording_id, public_id, recipient_label, headline, body_md, created_at, expires_at, revoked_at) · public_id is random, not sequential - artefacts(recording_id, kind, path) · poster frame, transcript, Markdown summary - settings(storage_ceiling_bytes, default_expiry_days, videos_dir) - has_audio and audio_source are recorded because the capture API is allowed to hand back a stream with no audio track even when you asked for one, and finding that out after the call is the standard way a recording is wasted. ### Phase 1 · Prove you captured what you asked for Build: Request the screen or window and the microphone, then inspect the returned stream: count the video and audio tracks, read their settings, and display what was actually granted before the countdown starts. Show a live input meter for the microphone. Refuse to start a recording that was meant to have audio and has no audio track. Trap: The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. Done when: Starting a recording with audio requested but no audio track returned is refused with an explanation naming what was granted, tested by denying the microphone. The interface shows which surface and which audio source were actually captured before recording starts, checked by review. A live input meter moves with real microphone input and shows nothing when the input is muted. The selected surface recorded is the one reported, tested by choosing a different window from the one the request suggested. Do not build yet: No encoding, trimming, pages or storage management yet. ### Phase 2 · A file that survives a crash and can be scrubbed Build: Write recorder chunks to numbered files as they arrive rather than holding them in memory. On stop, concatenate and remux with ffmpeg so the container carries duration and seek information. Keep the raw concatenation beside the remuxed file. Recover an interrupted recording on next launch from the chunks already on disk. Trap: Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. Done when: Killing the application mid-recording leaves recoverable chunks and the next launch offers to assemble them, tested by killing the process. The remuxed file reports a correct duration and seeks to an arbitrary timestamp in a player that is not this application, tested manually and by probing the container. The raw concatenation is kept alongside the remuxed output and neither overwrites the other. A recording is only marked complete after the remux succeeds, and a failed remux leaves the chunks in place. Do not build yet: No trimming, pages, transcript or storage limits yet. ### Phase 3 · Trimming that does not touch the original Build: Store a trim as a start and end offset against a recording. Render an export by cutting at the nearest preceding keyframe when copying streams, or by re-encoding when the cut has to be frame accurate, and record which was used. Generate a poster frame from the trimmed range. Trap: Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. Done when: A trim never modifies the source file, verified by comparing its hash before and after an export. A stream-copy export reports the actual start offset achieved, which may differ from the requested one, and the interface shows it. Choosing frame-accurate mode re-encodes and the trim row records that it did. The poster frame comes from within the trimmed range, tested at a trim that starts after the original first frame. Do not build yet: No recipient pages, transcript, storage ceiling or export bundle yet. ### Phase 4 · A page for one person, not a directory for everyone Build: Generate a page as static files: the video, the poster, a headline, some Markdown and one link. Name it with a long random identifier from a cryptographic source. Add an expiry date and a revoke action that removes the files. Include no analytics, no pixel, no beacon and no font or script from another origin. Trap: Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. Done when: Page identifiers come from a cryptographic random source, are of the configured length, and no identifier is derivable from the recording, the recipient or the creation order, asserted in the suite. An expired or revoked page returns nothing and its files are gone from disk, tested by checking the path. The generated page loads with no request to any origin other than its own, verified by watching the network with the page opened offline. Deleting a recording deletes its pages and their files in the same operation. Do not build yet: No storage ceiling, bundle export or retention yet. ### Phase 5 · Disk, deletion and getting a video out Build: Track total bytes used and refuse to start a new recording above the ceiling, naming what to delete. Expire recordings and their pages on a retention. Export a recording as a folder with the video, poster, transcript, Markdown summary and a metadata file. Provide reveal-in-folder and copy-path actions so nothing depends on this application to be useful. Trap: A recorder with no storage ceiling fills a laptop disk quietly over three months, and the failure arrives as an unrelated crash in a different application while a recording is in progress. Check free space and the configured ceiling before starting, and refuse with a list of the largest recordings rather than starting and failing midway. Done when: Starting a recording that would exceed the ceiling or the free space margin is refused before capture begins, with the largest recordings listed, tested by setting a small ceiling. Retention deletes recordings, their trims, artefacts and pages together, and the deletion is logged. An exported folder plays and reads on a machine without this application, tested. Reveal-in-folder and copy-path work for every recording, including one that has no page. Do not build yet: Nothing after this. ### Out of scope (and why) - Hosted sharing with a public link on your domain, which is an upload pipeline, a transcoding queue and a bandwidth bill rather than a weekend feature. - Viewer identity and engagement tracking, which is the part of the paid product that watches the recipient, and a page that phones home is a different product from a video you sent someone. - Bulk personalised video generation from a spreadsheet, which is a cold outreach machine and needs the platform rules of whatever you send it through read first. - Team libraries, seats and workspaces, which multiply every screen here by a permission model for a tool one person is using. ### README must contain - That the capture API can return a stream without the audio track you asked for, and that the build checks and blocks on it - That chunks are written to disk during recording and how to recover an interrupted session - That the output is remuxed before it is considered finished, and why an unremuxed file will not seek - That trims are non-destructive and that a stream-copy cut snaps to a keyframe, with the achieved offset shown - That recipient pages use cryptographically random identifiers, expire, and contain no tracking of any kind ===== AGENTS.md ===== # Agent instructions · Sendspark indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with React and TypeScript, one desktop application, The display and media capture APIs inside the webview, with tracks verified after the fact, ffmpeg invoked locally for remux and trim, originals kept, SQLite plus a videos directory, no upload anywhere, A static page folder with a random identifier, no tracking of any kind. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. - Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. - Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. - Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. ===== BUILD_PLAN.md ===== # Build plan · Sendspark A local screen and camera recorder whose hard problems are proving you captured the audio you think you captured, producing a file that seeks instead of one the browser refuses to scrub, and building a recipient page that cannot be found by guessing. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Prove you captured what you asked for Recording only starts once the tracks that came back are inspected and shown to the user. ### Steps 1. Inspect the returned tracks before starting The request is a suggestion; the tracks are the fact. Files: `src/capture/start.ts`, `src/capture/inspect.ts` 2. Show a live input meter and block on silence Two seconds of checking against forty minutes of silent video. Files: `src/components/InputMeter.tsx` 3. Record what was granted on the row Source kind, audio source, dimensions, at capture time. Files: `src/db/recordings.ts`, `tests/capture.test.ts` ### Done when - [ ] A missing audio track blocks the start with an explanation - [ ] Granted surface and audio source are shown before recording - [ ] The input meter reflects real microphone input - [ ] The row records what was actually granted ### Watch out - The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. ## Phase 2 · A file that survives a crash and can be scrubbed Chunks land on disk as they arrive and the finished file is remuxed so players can seek it. ### Steps 1. Write chunks to disk as they arrive Memory is where recordings go to die at minute thirty nine. Files: `src/capture/chunks.ts` 2. Remux before calling a recording finished Otherwise the player cannot seek and reports no duration. Files: `src-tauri/src/encode.rs`, `tests/remux.test.ts` ```sh ffmpeg -i raw.webm -c copy -fflags +genpts fixed.webm ffprobe -v error -show_entries format=duration fixed.webm ``` 3. Recover orphaned chunks on launch The crash already happened; do not also lose the footage. Files: `src/capture/recover.ts` ### Done when - [ ] A killed recording leaves recoverable chunks - [ ] The finished file seeks correctly in another player - [ ] The raw concatenation is retained - [ ] A failed remux leaves the chunks in place ### Watch out - Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. ## Phase 3 · Trimming that does not touch the original Cuts are stored as ranges, exports are rendered from them, and the source file is never replaced. ### Steps 1. Store trims as ranges, never as edits Non-destructive is the whole reason the original is kept. Files: `src/trim/model.ts` 2. Snap to keyframes or re-encode, and say which An inaccurate cut presented as exact is the annoying kind of bug. Files: `src-tauri/src/encode.rs`, `tests/trim.test.ts` 3. Generate the poster from inside the trimmed range The first frame of the source is usually a desktop. Files: `src/trim/poster.ts` ### Done when - [ ] The source hash is unchanged after an export - [ ] The achieved start offset is reported - [ ] Frame-accurate mode re-encodes and is recorded - [ ] The poster frame is within the trimmed range ### Watch out - Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. ## Phase 4 · A page for one person, not a directory for everyone Each recipient page has an unguessable identifier, an expiry and a revoke, and tracks nothing. ### Steps 1. Use a cryptographic random identifier Not a counter, not a hash of the recipient's email. Files: `src/pages/id.ts`, `tests/page-id.test.ts` 2. Emit a page with no third-party requests Fonts and analytics included, which means self-hosting both or neither. Files: `src/pages/render.ts` 3. Make revoke remove files, not flip a flag A flag on a static file server protects nothing. Files: `src/pages/revoke.ts` ### Done when - [ ] Identifiers are cryptographically random and non-derivable - [ ] Revoked and expired pages have no files on disk - [ ] The page makes no third-party request - [ ] Deleting a recording deletes its pages ### Watch out - Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. ## Not in this build - Hosted sharing on a public domain. - Viewer identity and engagement tracking. - Bulk personalised video from a spreadsheet. - Team libraries, seats and workspaces. ## After v1, if you want it - A local transcript pass over the audio, which makes old recordings findable without any upload - A second camera track composited in the corner, once the single-track path is reliable - A per-page short note the recipient can reply to by email, which is the only feedback worth having without tracking ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Chunks, raw concatenations, remuxed files and exports. VIDEOS_DIR=./data/videos # Required. Recordings, trims, pages and artefacts. DATABASE_PATH=./data/recordings.db # Required. A new recording is refused above this, before capture starts. STORAGE_CEILING_GB=20 # Required. Checked alongside the ceiling so the disk is never filled to zero. FREE_SPACE_MARGIN_GB=5 # Required. Length of the random page identifier. Longer is cheap here. PAGE_ID_BYTES=16 # Required. Recipient pages expire and their files are removed. DEFAULT_EXPIRY_DAYS=30 # Required. Resolved once at startup so a missing binary fails loudly, not mid-recording. FFMPEG_PATH=/usr/bin/ffmpeg
You are building a lean indie version of Sendspark. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Sendspark · indie build A local screen and camera recorder whose hard problems are proving you captured the audio you think you captured, producing a file that seeks instead of one the browser refuses to scrub, and building a recipient page that cannot be found by guessing. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Tauri 2 with React and TypeScript, one desktop application | the capture permissions and the file handling both want to be native | | Capture | The display and media capture APIs inside the webview, with tracks verified after the fact | what you asked for and what the operating system gave you are frequently different | | Encoding | ffmpeg invoked locally for remux and trim, originals kept | browser recorder output needs a pass before it behaves like a video file | | Storage | SQLite plus a videos directory, no upload anywhere | a personal video to one person is not something to put on someone else's disk by default | | Sharing | A static page folder with a random identifier, no tracking of any kind | an unguessable link is the honest version of access control here | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Rust toolchain and the Tauri 2 prerequisites** · free - Why: Builds the desktop application and the native side that shells out to ffmpeg. - Get it: rustup, then your platform's Tauri prerequisites. - Verify: A Tauri template builds and launches on your machine - [ ] **Node.js 22 or newer** · free - Why: Builds the front end. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **ffmpeg and ffprobe available to the application** · free - Why: The remux pass is what makes the recording a file that seeks, and ffprobe is how the build verifies it. - Get it: Your package manager, or bundle a static build with the application. - Verify: ffprobe reports a duration for a remuxed test file - [ ] **Screen recording and microphone permission on your operating system** · free - Why: Both are granted outside your application and both fail in ways that look like a bug in your code, usually on the first run after a system update. - Get it: Grant them in the system settings for your build, not only for your development runner. - Verify: A capture from the packaged build returns both a video and an audio track - [ ] **How recipient pages are served and for how long** · free to a few pounds a month - Why: A page nobody can reach is not shared, and a page that never expires is a personal video sitting on a disk for years, so this is decided before the page generator exists. - Get it: Decide where the folders are served from, the default expiry, and the identifier length. - Verify: All three appear in the configuration and in the README ## Quick start ```sh ffmpeg -i raw.webm -c copy -fflags +genpts fixed.webm ffprobe -v error -show_entries format=duration fixed.webm ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Hosted sharing on a public domain. - Viewer identity and engagement tracking. - Bulk personalised video from a spreadsheet. - Team libraries, seats and workspaces. - personalization at scale, CRM workflows, hosting, and engagement tracking - instant hosted sharing - viewer analytics - team libraries - cross-device capture If one of those is essential to you, that is the reason to keep paying for Sendspark, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Sendspark The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a desktop recorder that makes a short personal video and a small page for one recipient, both stored on your own machine to replace Sendspark. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Tauri 2 with React and TypeScript, one desktop application · the capture permissions and the file handling both want to be native - Capture: The display and media capture APIs inside the webview, with tracks verified after the fact · what you asked for and what the operating system gave you are frequently different - Encoding: ffmpeg invoked locally for remux and trim, originals kept · browser recorder output needs a pass before it behaves like a video file - Storage: SQLite plus a videos directory, no upload anywhere · a personal video to one person is not something to put on someone else's disk by default - Sharing: A static page folder with a random identifier, no tracking of any kind · an unguessable link is the honest version of access control here ### Data model (create this before Phase 1) - recordings(id, title, created_at, source_kind, duration_ms, width, height, has_audio, audio_source, raw_path, remuxed_path, sha256, bytes) - chunks(recording_id, ordinal, path, bytes, written_at) · written to disk as they arrive so a crash keeps what was recorded - trims(id, recording_id, start_ms, end_ms, created_at, output_path, reencoded) · non-destructive, the original is never replaced - pages(id, recording_id, public_id, recipient_label, headline, body_md, created_at, expires_at, revoked_at) · public_id is random, not sequential - artefacts(recording_id, kind, path) · poster frame, transcript, Markdown summary - settings(storage_ceiling_bytes, default_expiry_days, videos_dir) - has_audio and audio_source are recorded because the capture API is allowed to hand back a stream with no audio track even when you asked for one, and finding that out after the call is the standard way a recording is wasted. ### Phase 1 · Prove you captured what you asked for Build: Request the screen or window and the microphone, then inspect the returned stream: count the video and audio tracks, read their settings, and display what was actually granted before the countdown starts. Show a live input meter for the microphone. Refuse to start a recording that was meant to have audio and has no audio track. Trap: The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. Done when: Starting a recording with audio requested but no audio track returned is refused with an explanation naming what was granted, tested by denying the microphone. The interface shows which surface and which audio source were actually captured before recording starts, checked by review. A live input meter moves with real microphone input and shows nothing when the input is muted. The selected surface recorded is the one reported, tested by choosing a different window from the one the request suggested. Do not build yet: No encoding, trimming, pages or storage management yet. ### Phase 2 · A file that survives a crash and can be scrubbed Build: Write recorder chunks to numbered files as they arrive rather than holding them in memory. On stop, concatenate and remux with ffmpeg so the container carries duration and seek information. Keep the raw concatenation beside the remuxed file. Recover an interrupted recording on next launch from the chunks already on disk. Trap: Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. Done when: Killing the application mid-recording leaves recoverable chunks and the next launch offers to assemble them, tested by killing the process. The remuxed file reports a correct duration and seeks to an arbitrary timestamp in a player that is not this application, tested manually and by probing the container. The raw concatenation is kept alongside the remuxed output and neither overwrites the other. A recording is only marked complete after the remux succeeds, and a failed remux leaves the chunks in place. Do not build yet: No trimming, pages, transcript or storage limits yet. ### Phase 3 · Trimming that does not touch the original Build: Store a trim as a start and end offset against a recording. Render an export by cutting at the nearest preceding keyframe when copying streams, or by re-encoding when the cut has to be frame accurate, and record which was used. Generate a poster frame from the trimmed range. Trap: Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. Done when: A trim never modifies the source file, verified by comparing its hash before and after an export. A stream-copy export reports the actual start offset achieved, which may differ from the requested one, and the interface shows it. Choosing frame-accurate mode re-encodes and the trim row records that it did. The poster frame comes from within the trimmed range, tested at a trim that starts after the original first frame. Do not build yet: No recipient pages, transcript, storage ceiling or export bundle yet. ### Phase 4 · A page for one person, not a directory for everyone Build: Generate a page as static files: the video, the poster, a headline, some Markdown and one link. Name it with a long random identifier from a cryptographic source. Add an expiry date and a revoke action that removes the files. Include no analytics, no pixel, no beacon and no font or script from another origin. Trap: Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. Done when: Page identifiers come from a cryptographic random source, are of the configured length, and no identifier is derivable from the recording, the recipient or the creation order, asserted in the suite. An expired or revoked page returns nothing and its files are gone from disk, tested by checking the path. The generated page loads with no request to any origin other than its own, verified by watching the network with the page opened offline. Deleting a recording deletes its pages and their files in the same operation. Do not build yet: No storage ceiling, bundle export or retention yet. ### Phase 5 · Disk, deletion and getting a video out Build: Track total bytes used and refuse to start a new recording above the ceiling, naming what to delete. Expire recordings and their pages on a retention. Export a recording as a folder with the video, poster, transcript, Markdown summary and a metadata file. Provide reveal-in-folder and copy-path actions so nothing depends on this application to be useful. Trap: A recorder with no storage ceiling fills a laptop disk quietly over three months, and the failure arrives as an unrelated crash in a different application while a recording is in progress. Check free space and the configured ceiling before starting, and refuse with a list of the largest recordings rather than starting and failing midway. Done when: Starting a recording that would exceed the ceiling or the free space margin is refused before capture begins, with the largest recordings listed, tested by setting a small ceiling. Retention deletes recordings, their trims, artefacts and pages together, and the deletion is logged. An exported folder plays and reads on a machine without this application, tested. Reveal-in-folder and copy-path work for every recording, including one that has no page. Do not build yet: Nothing after this. ### Out of scope (and why) - Hosted sharing with a public link on your domain, which is an upload pipeline, a transcoding queue and a bandwidth bill rather than a weekend feature. - Viewer identity and engagement tracking, which is the part of the paid product that watches the recipient, and a page that phones home is a different product from a video you sent someone. - Bulk personalised video generation from a spreadsheet, which is a cold outreach machine and needs the platform rules of whatever you send it through read first. - Team libraries, seats and workspaces, which multiply every screen here by a permission model for a tool one person is using. ### README must contain - That the capture API can return a stream without the audio track you asked for, and that the build checks and blocks on it - That chunks are written to disk during recording and how to recover an interrupted session - That the output is remuxed before it is considered finished, and why an unremuxed file will not seek - That trims are non-destructive and that a stream-copy cut snaps to a keyframe, with the achieved offset shown - That recipient pages use cryptographically random identifiers, expire, and contain no tracking of any kind ===== AGENTS.md ===== # Agent instructions · Sendspark indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with React and TypeScript, one desktop application, The display and media capture APIs inside the webview, with tracks verified after the fact, ffmpeg invoked locally for remux and trim, originals kept, SQLite plus a videos directory, no upload anywhere, A static page folder with a random identifier, no tracking of any kind. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. - Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. - Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. - Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. ===== BUILD_PLAN.md ===== # Build plan · Sendspark A local screen and camera recorder whose hard problems are proving you captured the audio you think you captured, producing a file that seeks instead of one the browser refuses to scrub, and building a recipient page that cannot be found by guessing. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Prove you captured what you asked for Recording only starts once the tracks that came back are inspected and shown to the user. ### Steps 1. Inspect the returned tracks before starting The request is a suggestion; the tracks are the fact. Files: `src/capture/start.ts`, `src/capture/inspect.ts` 2. Show a live input meter and block on silence Two seconds of checking against forty minutes of silent video. Files: `src/components/InputMeter.tsx` 3. Record what was granted on the row Source kind, audio source, dimensions, at capture time. Files: `src/db/recordings.ts`, `tests/capture.test.ts` ### Done when - [ ] A missing audio track blocks the start with an explanation - [ ] Granted surface and audio source are shown before recording - [ ] The input meter reflects real microphone input - [ ] The row records what was actually granted ### Watch out - The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. ## Phase 2 · A file that survives a crash and can be scrubbed Chunks land on disk as they arrive and the finished file is remuxed so players can seek it. ### Steps 1. Write chunks to disk as they arrive Memory is where recordings go to die at minute thirty nine. Files: `src/capture/chunks.ts` 2. Remux before calling a recording finished Otherwise the player cannot seek and reports no duration. Files: `src-tauri/src/encode.rs`, `tests/remux.test.ts` ```sh ffmpeg -i raw.webm -c copy -fflags +genpts fixed.webm ffprobe -v error -show_entries format=duration fixed.webm ``` 3. Recover orphaned chunks on launch The crash already happened; do not also lose the footage. Files: `src/capture/recover.ts` ### Done when - [ ] A killed recording leaves recoverable chunks - [ ] The finished file seeks correctly in another player - [ ] The raw concatenation is retained - [ ] A failed remux leaves the chunks in place ### Watch out - Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. ## Phase 3 · Trimming that does not touch the original Cuts are stored as ranges, exports are rendered from them, and the source file is never replaced. ### Steps 1. Store trims as ranges, never as edits Non-destructive is the whole reason the original is kept. Files: `src/trim/model.ts` 2. Snap to keyframes or re-encode, and say which An inaccurate cut presented as exact is the annoying kind of bug. Files: `src-tauri/src/encode.rs`, `tests/trim.test.ts` 3. Generate the poster from inside the trimmed range The first frame of the source is usually a desktop. Files: `src/trim/poster.ts` ### Done when - [ ] The source hash is unchanged after an export - [ ] The achieved start offset is reported - [ ] Frame-accurate mode re-encodes and is recorded - [ ] The poster frame is within the trimmed range ### Watch out - Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. ## Phase 4 · A page for one person, not a directory for everyone Each recipient page has an unguessable identifier, an expiry and a revoke, and tracks nothing. ### Steps 1. Use a cryptographic random identifier Not a counter, not a hash of the recipient's email. Files: `src/pages/id.ts`, `tests/page-id.test.ts` 2. Emit a page with no third-party requests Fonts and analytics included, which means self-hosting both or neither. Files: `src/pages/render.ts` 3. Make revoke remove files, not flip a flag A flag on a static file server protects nothing. Files: `src/pages/revoke.ts` ### Done when - [ ] Identifiers are cryptographically random and non-derivable - [ ] Revoked and expired pages have no files on disk - [ ] The page makes no third-party request - [ ] Deleting a recording deletes its pages ### Watch out - Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. ## Not in this build - Hosted sharing on a public domain. - Viewer identity and engagement tracking. - Bulk personalised video from a spreadsheet. - Team libraries, seats and workspaces. ## After v1, if you want it - A local transcript pass over the audio, which makes old recordings findable without any upload - A second camera track composited in the corner, once the single-track path is reliable - A per-page short note the recipient can reply to by email, which is the only feedback worth having without tracking ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Chunks, raw concatenations, remuxed files and exports. VIDEOS_DIR=./data/videos # Required. Recordings, trims, pages and artefacts. DATABASE_PATH=./data/recordings.db # Required. A new recording is refused above this, before capture starts. STORAGE_CEILING_GB=20 # Required. Checked alongside the ceiling so the disk is never filled to zero. FREE_SPACE_MARGIN_GB=5 # Required. Length of the random page identifier. Longer is cheap here. PAGE_ID_BYTES=16 # Required. Recipient pages expire and their files are removed. DEFAULT_EXPIRY_DAYS=30 # Required. Resolved once at startup so a missing binary fails loudly, not mid-recording. FFMPEG_PATH=/usr/bin/ffmpeg
You are building a production product version of Sendspark. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Sendspark · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Sendspark, record lightweight personalized videos and generate recipient-specific landing pages. The hard boundary is personalization at scale, crm workflows, hosting, and engagement tracking, plus capture polish, hosting, and collaboration. ## Product outcome A recorder that tells you what it actually captured before you start, keeps the footage when it crashes, produces a file that scrubs, and makes a page for one person that expires and watches nobody. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - macOS or Windows screen-recording permission - microphone permission - ffmpeg - local disk space ## Explicit non-goals for v1 - Hosted sharing on a public domain. - Viewer identity and engagement tracking. - Bulk personalised video from a spreadsheet. - Team libraries, seats and workspaces. - personalization at scale, CRM workflows, hosting, and engagement tracking - instant hosted sharing - viewer analytics - team libraries - cross-device capture ## Success criteria - A missing audio track is confirmed to block the start - A killed recording is confirmed recoverable from chunks - The finished file is confirmed to seek in another player - Page identifiers are confirmed cryptographically random and non-derivable - An over-ceiling recording is confirmed refused before capture ===== BRIEF.md ===== # Build brief · Sendspark The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a desktop recorder that makes a short personal video and a small page for one recipient, both stored on your own machine to replace Sendspark. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Tauri 2 with React and TypeScript, one desktop application · the capture permissions and the file handling both want to be native - Capture: The display and media capture APIs inside the webview, with tracks verified after the fact · what you asked for and what the operating system gave you are frequently different - Encoding: ffmpeg invoked locally for remux and trim, originals kept · browser recorder output needs a pass before it behaves like a video file - Storage: SQLite plus a videos directory, no upload anywhere · a personal video to one person is not something to put on someone else's disk by default - Sharing: A static page folder with a random identifier, no tracking of any kind · an unguessable link is the honest version of access control here ### Data model (create this before Phase 1) - recordings(id, title, created_at, source_kind, duration_ms, width, height, has_audio, audio_source, raw_path, remuxed_path, sha256, bytes) - chunks(recording_id, ordinal, path, bytes, written_at) · written to disk as they arrive so a crash keeps what was recorded - trims(id, recording_id, start_ms, end_ms, created_at, output_path, reencoded) · non-destructive, the original is never replaced - pages(id, recording_id, public_id, recipient_label, headline, body_md, created_at, expires_at, revoked_at) · public_id is random, not sequential - artefacts(recording_id, kind, path) · poster frame, transcript, Markdown summary - settings(storage_ceiling_bytes, default_expiry_days, videos_dir) - has_audio and audio_source are recorded because the capture API is allowed to hand back a stream with no audio track even when you asked for one, and finding that out after the call is the standard way a recording is wasted. ### Phase 1 · Prove you captured what you asked for Build: Request the screen or window and the microphone, then inspect the returned stream: count the video and audio tracks, read their settings, and display what was actually granted before the countdown starts. Show a live input meter for the microphone. Refuse to start a recording that was meant to have audio and has no audio track. Trap: The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. Done when: Starting a recording with audio requested but no audio track returned is refused with an explanation naming what was granted, tested by denying the microphone. The interface shows which surface and which audio source were actually captured before recording starts, checked by review. A live input meter moves with real microphone input and shows nothing when the input is muted. The selected surface recorded is the one reported, tested by choosing a different window from the one the request suggested. Do not build yet: No encoding, trimming, pages or storage management yet. ### Phase 2 · A file that survives a crash and can be scrubbed Build: Write recorder chunks to numbered files as they arrive rather than holding them in memory. On stop, concatenate and remux with ffmpeg so the container carries duration and seek information. Keep the raw concatenation beside the remuxed file. Recover an interrupted recording on next launch from the chunks already on disk. Trap: Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. Done when: Killing the application mid-recording leaves recoverable chunks and the next launch offers to assemble them, tested by killing the process. The remuxed file reports a correct duration and seeks to an arbitrary timestamp in a player that is not this application, tested manually and by probing the container. The raw concatenation is kept alongside the remuxed output and neither overwrites the other. A recording is only marked complete after the remux succeeds, and a failed remux leaves the chunks in place. Do not build yet: No trimming, pages, transcript or storage limits yet. ### Phase 3 · Trimming that does not touch the original Build: Store a trim as a start and end offset against a recording. Render an export by cutting at the nearest preceding keyframe when copying streams, or by re-encoding when the cut has to be frame accurate, and record which was used. Generate a poster frame from the trimmed range. Trap: Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. Done when: A trim never modifies the source file, verified by comparing its hash before and after an export. A stream-copy export reports the actual start offset achieved, which may differ from the requested one, and the interface shows it. Choosing frame-accurate mode re-encodes and the trim row records that it did. The poster frame comes from within the trimmed range, tested at a trim that starts after the original first frame. Do not build yet: No recipient pages, transcript, storage ceiling or export bundle yet. ### Phase 4 · A page for one person, not a directory for everyone Build: Generate a page as static files: the video, the poster, a headline, some Markdown and one link. Name it with a long random identifier from a cryptographic source. Add an expiry date and a revoke action that removes the files. Include no analytics, no pixel, no beacon and no font or script from another origin. Trap: Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. Done when: Page identifiers come from a cryptographic random source, are of the configured length, and no identifier is derivable from the recording, the recipient or the creation order, asserted in the suite. An expired or revoked page returns nothing and its files are gone from disk, tested by checking the path. The generated page loads with no request to any origin other than its own, verified by watching the network with the page opened offline. Deleting a recording deletes its pages and their files in the same operation. Do not build yet: No storage ceiling, bundle export or retention yet. ### Phase 5 · Disk, deletion and getting a video out Build: Track total bytes used and refuse to start a new recording above the ceiling, naming what to delete. Expire recordings and their pages on a retention. Export a recording as a folder with the video, poster, transcript, Markdown summary and a metadata file. Provide reveal-in-folder and copy-path actions so nothing depends on this application to be useful. Trap: A recorder with no storage ceiling fills a laptop disk quietly over three months, and the failure arrives as an unrelated crash in a different application while a recording is in progress. Check free space and the configured ceiling before starting, and refuse with a list of the largest recordings rather than starting and failing midway. Done when: Starting a recording that would exceed the ceiling or the free space margin is refused before capture begins, with the largest recordings listed, tested by setting a small ceiling. Retention deletes recordings, their trims, artefacts and pages together, and the deletion is logged. An exported folder plays and reads on a machine without this application, tested. Reveal-in-folder and copy-path work for every recording, including one that has no page. Do not build yet: Nothing after this. ### Out of scope (and why) - Hosted sharing with a public link on your domain, which is an upload pipeline, a transcoding queue and a bandwidth bill rather than a weekend feature. - Viewer identity and engagement tracking, which is the part of the paid product that watches the recipient, and a page that phones home is a different product from a video you sent someone. - Bulk personalised video generation from a spreadsheet, which is a cold outreach machine and needs the platform rules of whatever you send it through read first. - Team libraries, seats and workspaces, which multiply every screen here by a permission model for a tool one person is using. ### README must contain - That the capture API can return a stream without the audio track you asked for, and that the build checks and blocks on it - That chunks are written to disk during recording and how to recover an interrupted session - That the output is remuxed before it is considered finished, and why an unremuxed file will not seek - That trims are non-destructive and that a stream-copy cut snaps to a keyframe, with the achieved offset shown - That recipient pages use cryptographically random identifiers, expire, and contain no tracking of any kind ===== ARCHITECTURE.md ===== # Architecture · Sendspark ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Tauri 2 with React and TypeScript, one desktop application | the capture permissions and the file handling both want to be native | | Capture | The display and media capture APIs inside the webview, with tracks verified after the fact | what you asked for and what the operating system gave you are frequently different | | Encoding | ffmpeg invoked locally for remux and trim, originals kept | browser recorder output needs a pass before it behaves like a video file | | Storage | SQLite plus a videos directory, no upload anywhere | a personal video to one person is not something to put on someone else's disk by default | | Sharing | A static page folder with a random identifier, no tracking of any kind | an unguessable link is the honest version of access control here | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Capture | Track inspection, the meter and the granted-source record. | Any capture path, provided tracks are verified after the request. | | Chunks | Disk writes during recording and crash recovery. | Any writer, provided nothing is held only in memory. | | Encode | Remux, trim rendering and poster frames. | Any encoder, provided the original is never replaced. | | Pages | Random identifiers, expiry and revoke. | Any host, provided identifiers stay unguessable and revoke deletes files. | | Storage | Ceilings, retention and export bundles. | Any policy, provided the check happens before capture starts. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `VIDEOS_DIR` · required · Chunks, raw concatenations, remuxed files and exports. - `DATABASE_PATH` · required · Recordings, trims, pages and artefacts. - `STORAGE_CEILING_GB` · required · A new recording is refused above this, before capture starts. - `FREE_SPACE_MARGIN_GB` · required · Checked alongside the ceiling so the disk is never filled to zero. - `PAGE_ID_BYTES` · required · Length of the random page identifier. Longer is cheap here. - `DEFAULT_EXPIRY_DAYS` · required · Recipient pages expire and their files are removed. - `FFMPEG_PATH` · required · Resolved once at startup so a missing binary fails loudly, not mid-recording. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Sendspark product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Tauri 2 with React and TypeScript, one desktop application, The display and media capture APIs inside the webview, with tracks verified after the fact, ffmpeg invoked locally for remux and trim, originals kept, SQLite plus a videos directory, no upload anywhere, A static page folder with a random identifier, no tracking of any kind. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. - Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. - Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. - Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. - A recorder with no storage ceiling fills a laptop disk quietly over three months, and the failure arrives as an unrelated crash in a different application while a recording is in progress. Check free space and the configured ceiling before starting, and refuse with a list of the largest recordings rather than starting and failing midway. ===== MILESTONES.md ===== # Delivery milestones · Sendspark Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Prove you captured what you asked for Recording only starts once the tracks that came back are inspected and shown to the user. ### Steps 1. Inspect the returned tracks before starting The request is a suggestion; the tracks are the fact. Files: `src/capture/start.ts`, `src/capture/inspect.ts` 2. Show a live input meter and block on silence Two seconds of checking against forty minutes of silent video. Files: `src/components/InputMeter.tsx` 3. Record what was granted on the row Source kind, audio source, dimensions, at capture time. Files: `src/db/recordings.ts`, `tests/capture.test.ts` ### Done when - [ ] A missing audio track blocks the start with an explanation - [ ] Granted surface and audio source are shown before recording - [ ] The input meter reflects real microphone input - [ ] The row records what was actually granted ### Watch out - The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. ## M2 · A file that survives a crash and can be scrubbed Chunks land on disk as they arrive and the finished file is remuxed so players can seek it. ### Steps 1. Write chunks to disk as they arrive Memory is where recordings go to die at minute thirty nine. Files: `src/capture/chunks.ts` 2. Remux before calling a recording finished Otherwise the player cannot seek and reports no duration. Files: `src-tauri/src/encode.rs`, `tests/remux.test.ts` ```sh ffmpeg -i raw.webm -c copy -fflags +genpts fixed.webm ffprobe -v error -show_entries format=duration fixed.webm ``` 3. Recover orphaned chunks on launch The crash already happened; do not also lose the footage. Files: `src/capture/recover.ts` ### Done when - [ ] A killed recording leaves recoverable chunks - [ ] The finished file seeks correctly in another player - [ ] The raw concatenation is retained - [ ] A failed remux leaves the chunks in place ### Watch out - Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. ## M3 · Trimming that does not touch the original Cuts are stored as ranges, exports are rendered from them, and the source file is never replaced. ### Steps 1. Store trims as ranges, never as edits Non-destructive is the whole reason the original is kept. Files: `src/trim/model.ts` 2. Snap to keyframes or re-encode, and say which An inaccurate cut presented as exact is the annoying kind of bug. Files: `src-tauri/src/encode.rs`, `tests/trim.test.ts` 3. Generate the poster from inside the trimmed range The first frame of the source is usually a desktop. Files: `src/trim/poster.ts` ### Done when - [ ] The source hash is unchanged after an export - [ ] The achieved start offset is reported - [ ] Frame-accurate mode re-encodes and is recorded - [ ] The poster frame is within the trimmed range ### Watch out - Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. ## M4 · A page for one person, not a directory for everyone Each recipient page has an unguessable identifier, an expiry and a revoke, and tracks nothing. ### Steps 1. Use a cryptographic random identifier Not a counter, not a hash of the recipient's email. Files: `src/pages/id.ts`, `tests/page-id.test.ts` 2. Emit a page with no third-party requests Fonts and analytics included, which means self-hosting both or neither. Files: `src/pages/render.ts` 3. Make revoke remove files, not flip a flag A flag on a static file server protects nothing. Files: `src/pages/revoke.ts` ### Done when - [ ] Identifiers are cryptographically random and non-derivable - [ ] Revoked and expired pages have no files on disk - [ ] The page makes no third-party request - [ ] Deleting a recording deletes its pages ### Watch out - Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. ## M5 · Disk, deletion and getting a video out (production only) Storage has a ceiling, old recordings expire, and any recording leaves as a self-contained folder. ### Steps 1. Check space and ceiling before capture starts Failing at minute nought is far better than at minute thirty. Files: `src/storage/guard.ts`, `tests/storage.test.ts` 2. Expire recordings with their pages and artefacts In one operation, with a deletion log. Files: `src/storage/retention.ts` 3. Export a folder that works on its own Video, poster, transcript, summary, metadata. Files: `src/export/bundle.ts` ### Done when - [ ] An over-ceiling recording is refused before capture - [ ] Retention removes recordings, pages and artefacts together - [ ] An exported folder works without the application - [ ] Reveal and copy-path work for every recording ### Watch out - A recorder with no storage ceiling fills a laptop disk quietly over three months, and the failure arrives as an unrelated crash in a different application while a recording is in progress. Check free space and the configured ceiling before starting, and refuse with a list of the largest recordings rather than starting and failing midway. ===== OPERATIONS.md ===== # Operations · Sendspark ## Backup The videos directory and the database together. Video is large and mostly disposable, but the trims and pages are metadata that cannot be recreated from the files alone. ## Restore Restore both, then open a recording and confirm the trim ranges still map to the right moments and that pages resolve. A restore that loses the database leaves a folder of anonymous video files. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Free space against the ceiling, remux failure rate, and the number of orphaned chunk directories. Orphans accumulating means the application is crashing during capture and nobody has mentioned it. ## Incident checklist The two incidents that matter are a lost recording and a page reaching the wrong person. Chunk recovery addresses the first, and random identifiers with a real revoke address the second. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A missing audio track is confirmed to block the start - [ ] A killed recording is confirmed recoverable from chunks - [ ] The finished file is confirmed to seek in another player - [ ] Page identifiers are confirmed cryptographically random and non-derivable - [ ] An over-ceiling recording is confirmed refused before capture ## Launch constraint Do not market omitted Sendspark capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Chunks, raw concatenations, remuxed files and exports. VIDEOS_DIR=./data/videos # Required. Recordings, trims, pages and artefacts. DATABASE_PATH=./data/recordings.db # Required. A new recording is refused above this, before capture starts. STORAGE_CEILING_GB=20 # Required. Checked alongside the ceiling so the disk is never filled to zero. FREE_SPACE_MARGIN_GB=5 # Required. Length of the random page identifier. Longer is cheap here. PAGE_ID_BYTES=16 # Required. Recipient pages expire and their files are removed. DEFAULT_EXPIRY_DAYS=30 # Required. Resolved once at startup so a missing binary fails loudly, not mid-recording. FFMPEG_PATH=/usr/bin/ffmpeg
# Sendspark · indie build A local screen and camera recorder whose hard problems are proving you captured the audio you think you captured, producing a file that seeks instead of one the browser refuses to scrub, and building a recipient page that cannot be found by guessing. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Tauri 2 with React and TypeScript, one desktop application | the capture permissions and the file handling both want to be native | | Capture | The display and media capture APIs inside the webview, with tracks verified after the fact | what you asked for and what the operating system gave you are frequently different | | Encoding | ffmpeg invoked locally for remux and trim, originals kept | browser recorder output needs a pass before it behaves like a video file | | Storage | SQLite plus a videos directory, no upload anywhere | a personal video to one person is not something to put on someone else's disk by default | | Sharing | A static page folder with a random identifier, no tracking of any kind | an unguessable link is the honest version of access control here | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Rust toolchain and the Tauri 2 prerequisites** · free - Why: Builds the desktop application and the native side that shells out to ffmpeg. - Get it: rustup, then your platform's Tauri prerequisites. - Verify: A Tauri template builds and launches on your machine - [ ] **Node.js 22 or newer** · free - Why: Builds the front end. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **ffmpeg and ffprobe available to the application** · free - Why: The remux pass is what makes the recording a file that seeks, and ffprobe is how the build verifies it. - Get it: Your package manager, or bundle a static build with the application. - Verify: ffprobe reports a duration for a remuxed test file - [ ] **Screen recording and microphone permission on your operating system** · free - Why: Both are granted outside your application and both fail in ways that look like a bug in your code, usually on the first run after a system update. - Get it: Grant them in the system settings for your build, not only for your development runner. - Verify: A capture from the packaged build returns both a video and an audio track - [ ] **How recipient pages are served and for how long** · free to a few pounds a month - Why: A page nobody can reach is not shared, and a page that never expires is a personal video sitting on a disk for years, so this is decided before the page generator exists. - Get it: Decide where the folders are served from, the default expiry, and the identifier length. - Verify: All three appear in the configuration and in the README ## Quick start ```sh ffmpeg -i raw.webm -c copy -fflags +genpts fixed.webm ffprobe -v error -show_entries format=duration fixed.webm ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Hosted sharing on a public domain. - Viewer identity and engagement tracking. - Bulk personalised video from a spreadsheet. - Team libraries, seats and workspaces. - personalization at scale, CRM workflows, hosting, and engagement tracking - instant hosted sharing - viewer analytics - team libraries - cross-device capture If one of those is essential to you, that is the reason to keep paying for Sendspark, and the README should say so rather than pretend.
# Build brief · Sendspark The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a desktop recorder that makes a short personal video and a small page for one recipient, both stored on your own machine to replace Sendspark. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Tauri 2 with React and TypeScript, one desktop application · the capture permissions and the file handling both want to be native - Capture: The display and media capture APIs inside the webview, with tracks verified after the fact · what you asked for and what the operating system gave you are frequently different - Encoding: ffmpeg invoked locally for remux and trim, originals kept · browser recorder output needs a pass before it behaves like a video file - Storage: SQLite plus a videos directory, no upload anywhere · a personal video to one person is not something to put on someone else's disk by default - Sharing: A static page folder with a random identifier, no tracking of any kind · an unguessable link is the honest version of access control here ### Data model (create this before Phase 1) - recordings(id, title, created_at, source_kind, duration_ms, width, height, has_audio, audio_source, raw_path, remuxed_path, sha256, bytes) - chunks(recording_id, ordinal, path, bytes, written_at) · written to disk as they arrive so a crash keeps what was recorded - trims(id, recording_id, start_ms, end_ms, created_at, output_path, reencoded) · non-destructive, the original is never replaced - pages(id, recording_id, public_id, recipient_label, headline, body_md, created_at, expires_at, revoked_at) · public_id is random, not sequential - artefacts(recording_id, kind, path) · poster frame, transcript, Markdown summary - settings(storage_ceiling_bytes, default_expiry_days, videos_dir) - has_audio and audio_source are recorded because the capture API is allowed to hand back a stream with no audio track even when you asked for one, and finding that out after the call is the standard way a recording is wasted. ### Phase 1 · Prove you captured what you asked for Build: Request the screen or window and the microphone, then inspect the returned stream: count the video and audio tracks, read their settings, and display what was actually granted before the countdown starts. Show a live input meter for the microphone. Refuse to start a recording that was meant to have audio and has no audio track. Trap: The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. Done when: Starting a recording with audio requested but no audio track returned is refused with an explanation naming what was granted, tested by denying the microphone. The interface shows which surface and which audio source were actually captured before recording starts, checked by review. A live input meter moves with real microphone input and shows nothing when the input is muted. The selected surface recorded is the one reported, tested by choosing a different window from the one the request suggested. Do not build yet: No encoding, trimming, pages or storage management yet. ### Phase 2 · A file that survives a crash and can be scrubbed Build: Write recorder chunks to numbered files as they arrive rather than holding them in memory. On stop, concatenate and remux with ffmpeg so the container carries duration and seek information. Keep the raw concatenation beside the remuxed file. Recover an interrupted recording on next launch from the chunks already on disk. Trap: Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. Done when: Killing the application mid-recording leaves recoverable chunks and the next launch offers to assemble them, tested by killing the process. The remuxed file reports a correct duration and seeks to an arbitrary timestamp in a player that is not this application, tested manually and by probing the container. The raw concatenation is kept alongside the remuxed output and neither overwrites the other. A recording is only marked complete after the remux succeeds, and a failed remux leaves the chunks in place. Do not build yet: No trimming, pages, transcript or storage limits yet. ### Phase 3 · Trimming that does not touch the original Build: Store a trim as a start and end offset against a recording. Render an export by cutting at the nearest preceding keyframe when copying streams, or by re-encoding when the cut has to be frame accurate, and record which was used. Generate a poster frame from the trimmed range. Trap: Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. Done when: A trim never modifies the source file, verified by comparing its hash before and after an export. A stream-copy export reports the actual start offset achieved, which may differ from the requested one, and the interface shows it. Choosing frame-accurate mode re-encodes and the trim row records that it did. The poster frame comes from within the trimmed range, tested at a trim that starts after the original first frame. Do not build yet: No recipient pages, transcript, storage ceiling or export bundle yet. ### Phase 4 · A page for one person, not a directory for everyone Build: Generate a page as static files: the video, the poster, a headline, some Markdown and one link. Name it with a long random identifier from a cryptographic source. Add an expiry date and a revoke action that removes the files. Include no analytics, no pixel, no beacon and no font or script from another origin. Trap: Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. Done when: Page identifiers come from a cryptographic random source, are of the configured length, and no identifier is derivable from the recording, the recipient or the creation order, asserted in the suite. An expired or revoked page returns nothing and its files are gone from disk, tested by checking the path. The generated page loads with no request to any origin other than its own, verified by watching the network with the page opened offline. Deleting a recording deletes its pages and their files in the same operation. Do not build yet: No storage ceiling, bundle export or retention yet. ### Phase 5 · Disk, deletion and getting a video out Build: Track total bytes used and refuse to start a new recording above the ceiling, naming what to delete. Expire recordings and their pages on a retention. Export a recording as a folder with the video, poster, transcript, Markdown summary and a metadata file. Provide reveal-in-folder and copy-path actions so nothing depends on this application to be useful. Trap: A recorder with no storage ceiling fills a laptop disk quietly over three months, and the failure arrives as an unrelated crash in a different application while a recording is in progress. Check free space and the configured ceiling before starting, and refuse with a list of the largest recordings rather than starting and failing midway. Done when: Starting a recording that would exceed the ceiling or the free space margin is refused before capture begins, with the largest recordings listed, tested by setting a small ceiling. Retention deletes recordings, their trims, artefacts and pages together, and the deletion is logged. An exported folder plays and reads on a machine without this application, tested. Reveal-in-folder and copy-path work for every recording, including one that has no page. Do not build yet: Nothing after this. ### Out of scope (and why) - Hosted sharing with a public link on your domain, which is an upload pipeline, a transcoding queue and a bandwidth bill rather than a weekend feature. - Viewer identity and engagement tracking, which is the part of the paid product that watches the recipient, and a page that phones home is a different product from a video you sent someone. - Bulk personalised video generation from a spreadsheet, which is a cold outreach machine and needs the platform rules of whatever you send it through read first. - Team libraries, seats and workspaces, which multiply every screen here by a permission model for a tool one person is using. ### README must contain - That the capture API can return a stream without the audio track you asked for, and that the build checks and blocks on it - That chunks are written to disk during recording and how to recover an interrupted session - That the output is remuxed before it is considered finished, and why an unremuxed file will not seek - That trims are non-destructive and that a stream-copy cut snaps to a keyframe, with the achieved offset shown - That recipient pages use cryptographically random identifiers, expire, and contain no tracking of any kind
# Agent instructions · Sendspark indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with React and TypeScript, one desktop application, The display and media capture APIs inside the webview, with tracks verified after the fact, ffmpeg invoked locally for remux and trim, originals kept, SQLite plus a videos directory, no upload anywhere, A static page folder with a random identifier, no tracking of any kind. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. - Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. - Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. - Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient.
# Build plan · Sendspark A local screen and camera recorder whose hard problems are proving you captured the audio you think you captured, producing a file that seeks instead of one the browser refuses to scrub, and building a recipient page that cannot be found by guessing. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Prove you captured what you asked for Recording only starts once the tracks that came back are inspected and shown to the user. ### Steps 1. Inspect the returned tracks before starting The request is a suggestion; the tracks are the fact. Files: `src/capture/start.ts`, `src/capture/inspect.ts` 2. Show a live input meter and block on silence Two seconds of checking against forty minutes of silent video. Files: `src/components/InputMeter.tsx` 3. Record what was granted on the row Source kind, audio source, dimensions, at capture time. Files: `src/db/recordings.ts`, `tests/capture.test.ts` ### Done when - [ ] A missing audio track blocks the start with an explanation - [ ] Granted surface and audio source are shown before recording - [ ] The input meter reflects real microphone input - [ ] The row records what was actually granted ### Watch out - The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. ## Phase 2 · A file that survives a crash and can be scrubbed Chunks land on disk as they arrive and the finished file is remuxed so players can seek it. ### Steps 1. Write chunks to disk as they arrive Memory is where recordings go to die at minute thirty nine. Files: `src/capture/chunks.ts` 2. Remux before calling a recording finished Otherwise the player cannot seek and reports no duration. Files: `src-tauri/src/encode.rs`, `tests/remux.test.ts` ```sh ffmpeg -i raw.webm -c copy -fflags +genpts fixed.webm ffprobe -v error -show_entries format=duration fixed.webm ``` 3. Recover orphaned chunks on launch The crash already happened; do not also lose the footage. Files: `src/capture/recover.ts` ### Done when - [ ] A killed recording leaves recoverable chunks - [ ] The finished file seeks correctly in another player - [ ] The raw concatenation is retained - [ ] A failed remux leaves the chunks in place ### Watch out - Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. ## Phase 3 · Trimming that does not touch the original Cuts are stored as ranges, exports are rendered from them, and the source file is never replaced. ### Steps 1. Store trims as ranges, never as edits Non-destructive is the whole reason the original is kept. Files: `src/trim/model.ts` 2. Snap to keyframes or re-encode, and say which An inaccurate cut presented as exact is the annoying kind of bug. Files: `src-tauri/src/encode.rs`, `tests/trim.test.ts` 3. Generate the poster from inside the trimmed range The first frame of the source is usually a desktop. Files: `src/trim/poster.ts` ### Done when - [ ] The source hash is unchanged after an export - [ ] The achieved start offset is reported - [ ] Frame-accurate mode re-encodes and is recorded - [ ] The poster frame is within the trimmed range ### Watch out - Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. ## Phase 4 · A page for one person, not a directory for everyone Each recipient page has an unguessable identifier, an expiry and a revoke, and tracks nothing. ### Steps 1. Use a cryptographic random identifier Not a counter, not a hash of the recipient's email. Files: `src/pages/id.ts`, `tests/page-id.test.ts` 2. Emit a page with no third-party requests Fonts and analytics included, which means self-hosting both or neither. Files: `src/pages/render.ts` 3. Make revoke remove files, not flip a flag A flag on a static file server protects nothing. Files: `src/pages/revoke.ts` ### Done when - [ ] Identifiers are cryptographically random and non-derivable - [ ] Revoked and expired pages have no files on disk - [ ] The page makes no third-party request - [ ] Deleting a recording deletes its pages ### Watch out - Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. ## Not in this build - Hosted sharing on a public domain. - Viewer identity and engagement tracking. - Bulk personalised video from a spreadsheet. - Team libraries, seats and workspaces. ## After v1, if you want it - A local transcript pass over the audio, which makes old recordings findable without any upload - A second camera track composited in the corner, once the single-track path is reliable - A per-page short note the recipient can reply to by email, which is the only feedback worth having without tracking
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Chunks, raw concatenations, remuxed files and exports. VIDEOS_DIR=./data/videos # Required. Recordings, trims, pages and artefacts. DATABASE_PATH=./data/recordings.db # Required. A new recording is refused above this, before capture starts. STORAGE_CEILING_GB=20 # Required. Checked alongside the ceiling so the disk is never filled to zero. FREE_SPACE_MARGIN_GB=5 # Required. Length of the random page identifier. Longer is cheap here. PAGE_ID_BYTES=16 # Required. Recipient pages expire and their files are removed. DEFAULT_EXPIRY_DAYS=30 # Required. Resolved once at startup so a missing binary fails loudly, not mid-recording. FFMPEG_PATH=/usr/bin/ffmpeg
# Sendspark · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Sendspark, record lightweight personalized videos and generate recipient-specific landing pages. The hard boundary is personalization at scale, crm workflows, hosting, and engagement tracking, plus capture polish, hosting, and collaboration. ## Product outcome A recorder that tells you what it actually captured before you start, keeps the footage when it crashes, produces a file that scrubs, and makes a page for one person that expires and watches nobody. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - macOS or Windows screen-recording permission - microphone permission - ffmpeg - local disk space ## Explicit non-goals for v1 - Hosted sharing on a public domain. - Viewer identity and engagement tracking. - Bulk personalised video from a spreadsheet. - Team libraries, seats and workspaces. - personalization at scale, CRM workflows, hosting, and engagement tracking - instant hosted sharing - viewer analytics - team libraries - cross-device capture ## Success criteria - A missing audio track is confirmed to block the start - A killed recording is confirmed recoverable from chunks - The finished file is confirmed to seek in another player - Page identifiers are confirmed cryptographically random and non-derivable - An over-ceiling recording is confirmed refused before capture
# Build brief · Sendspark The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a desktop recorder that makes a short personal video and a small page for one recipient, both stored on your own machine to replace Sendspark. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Tauri 2 with React and TypeScript, one desktop application · the capture permissions and the file handling both want to be native - Capture: The display and media capture APIs inside the webview, with tracks verified after the fact · what you asked for and what the operating system gave you are frequently different - Encoding: ffmpeg invoked locally for remux and trim, originals kept · browser recorder output needs a pass before it behaves like a video file - Storage: SQLite plus a videos directory, no upload anywhere · a personal video to one person is not something to put on someone else's disk by default - Sharing: A static page folder with a random identifier, no tracking of any kind · an unguessable link is the honest version of access control here ### Data model (create this before Phase 1) - recordings(id, title, created_at, source_kind, duration_ms, width, height, has_audio, audio_source, raw_path, remuxed_path, sha256, bytes) - chunks(recording_id, ordinal, path, bytes, written_at) · written to disk as they arrive so a crash keeps what was recorded - trims(id, recording_id, start_ms, end_ms, created_at, output_path, reencoded) · non-destructive, the original is never replaced - pages(id, recording_id, public_id, recipient_label, headline, body_md, created_at, expires_at, revoked_at) · public_id is random, not sequential - artefacts(recording_id, kind, path) · poster frame, transcript, Markdown summary - settings(storage_ceiling_bytes, default_expiry_days, videos_dir) - has_audio and audio_source are recorded because the capture API is allowed to hand back a stream with no audio track even when you asked for one, and finding that out after the call is the standard way a recording is wasted. ### Phase 1 · Prove you captured what you asked for Build: Request the screen or window and the microphone, then inspect the returned stream: count the video and audio tracks, read their settings, and display what was actually granted before the countdown starts. Show a live input meter for the microphone. Refuse to start a recording that was meant to have audio and has no audio track. Trap: The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. Done when: Starting a recording with audio requested but no audio track returned is refused with an explanation naming what was granted, tested by denying the microphone. The interface shows which surface and which audio source were actually captured before recording starts, checked by review. A live input meter moves with real microphone input and shows nothing when the input is muted. The selected surface recorded is the one reported, tested by choosing a different window from the one the request suggested. Do not build yet: No encoding, trimming, pages or storage management yet. ### Phase 2 · A file that survives a crash and can be scrubbed Build: Write recorder chunks to numbered files as they arrive rather than holding them in memory. On stop, concatenate and remux with ffmpeg so the container carries duration and seek information. Keep the raw concatenation beside the remuxed file. Recover an interrupted recording on next launch from the chunks already on disk. Trap: Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. Done when: Killing the application mid-recording leaves recoverable chunks and the next launch offers to assemble them, tested by killing the process. The remuxed file reports a correct duration and seeks to an arbitrary timestamp in a player that is not this application, tested manually and by probing the container. The raw concatenation is kept alongside the remuxed output and neither overwrites the other. A recording is only marked complete after the remux succeeds, and a failed remux leaves the chunks in place. Do not build yet: No trimming, pages, transcript or storage limits yet. ### Phase 3 · Trimming that does not touch the original Build: Store a trim as a start and end offset against a recording. Render an export by cutting at the nearest preceding keyframe when copying streams, or by re-encoding when the cut has to be frame accurate, and record which was used. Generate a poster frame from the trimmed range. Trap: Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. Done when: A trim never modifies the source file, verified by comparing its hash before and after an export. A stream-copy export reports the actual start offset achieved, which may differ from the requested one, and the interface shows it. Choosing frame-accurate mode re-encodes and the trim row records that it did. The poster frame comes from within the trimmed range, tested at a trim that starts after the original first frame. Do not build yet: No recipient pages, transcript, storage ceiling or export bundle yet. ### Phase 4 · A page for one person, not a directory for everyone Build: Generate a page as static files: the video, the poster, a headline, some Markdown and one link. Name it with a long random identifier from a cryptographic source. Add an expiry date and a revoke action that removes the files. Include no analytics, no pixel, no beacon and no font or script from another origin. Trap: Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. Done when: Page identifiers come from a cryptographic random source, are of the configured length, and no identifier is derivable from the recording, the recipient or the creation order, asserted in the suite. An expired or revoked page returns nothing and its files are gone from disk, tested by checking the path. The generated page loads with no request to any origin other than its own, verified by watching the network with the page opened offline. Deleting a recording deletes its pages and their files in the same operation. Do not build yet: No storage ceiling, bundle export or retention yet. ### Phase 5 · Disk, deletion and getting a video out Build: Track total bytes used and refuse to start a new recording above the ceiling, naming what to delete. Expire recordings and their pages on a retention. Export a recording as a folder with the video, poster, transcript, Markdown summary and a metadata file. Provide reveal-in-folder and copy-path actions so nothing depends on this application to be useful. Trap: A recorder with no storage ceiling fills a laptop disk quietly over three months, and the failure arrives as an unrelated crash in a different application while a recording is in progress. Check free space and the configured ceiling before starting, and refuse with a list of the largest recordings rather than starting and failing midway. Done when: Starting a recording that would exceed the ceiling or the free space margin is refused before capture begins, with the largest recordings listed, tested by setting a small ceiling. Retention deletes recordings, their trims, artefacts and pages together, and the deletion is logged. An exported folder plays and reads on a machine without this application, tested. Reveal-in-folder and copy-path work for every recording, including one that has no page. Do not build yet: Nothing after this. ### Out of scope (and why) - Hosted sharing with a public link on your domain, which is an upload pipeline, a transcoding queue and a bandwidth bill rather than a weekend feature. - Viewer identity and engagement tracking, which is the part of the paid product that watches the recipient, and a page that phones home is a different product from a video you sent someone. - Bulk personalised video generation from a spreadsheet, which is a cold outreach machine and needs the platform rules of whatever you send it through read first. - Team libraries, seats and workspaces, which multiply every screen here by a permission model for a tool one person is using. ### README must contain - That the capture API can return a stream without the audio track you asked for, and that the build checks and blocks on it - That chunks are written to disk during recording and how to recover an interrupted session - That the output is remuxed before it is considered finished, and why an unremuxed file will not seek - That trims are non-destructive and that a stream-copy cut snaps to a keyframe, with the achieved offset shown - That recipient pages use cryptographically random identifiers, expire, and contain no tracking of any kind
# Architecture · Sendspark ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Tauri 2 with React and TypeScript, one desktop application | the capture permissions and the file handling both want to be native | | Capture | The display and media capture APIs inside the webview, with tracks verified after the fact | what you asked for and what the operating system gave you are frequently different | | Encoding | ffmpeg invoked locally for remux and trim, originals kept | browser recorder output needs a pass before it behaves like a video file | | Storage | SQLite plus a videos directory, no upload anywhere | a personal video to one person is not something to put on someone else's disk by default | | Sharing | A static page folder with a random identifier, no tracking of any kind | an unguessable link is the honest version of access control here | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Capture | Track inspection, the meter and the granted-source record. | Any capture path, provided tracks are verified after the request. | | Chunks | Disk writes during recording and crash recovery. | Any writer, provided nothing is held only in memory. | | Encode | Remux, trim rendering and poster frames. | Any encoder, provided the original is never replaced. | | Pages | Random identifiers, expiry and revoke. | Any host, provided identifiers stay unguessable and revoke deletes files. | | Storage | Ceilings, retention and export bundles. | Any policy, provided the check happens before capture starts. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `VIDEOS_DIR` · required · Chunks, raw concatenations, remuxed files and exports. - `DATABASE_PATH` · required · Recordings, trims, pages and artefacts. - `STORAGE_CEILING_GB` · required · A new recording is refused above this, before capture starts. - `FREE_SPACE_MARGIN_GB` · required · Checked alongside the ceiling so the disk is never filled to zero. - `PAGE_ID_BYTES` · required · Length of the random page identifier. Longer is cheap here. - `DEFAULT_EXPIRY_DAYS` · required · Recipient pages expire and their files are removed. - `FFMPEG_PATH` · required · Resolved once at startup so a missing binary fails loudly, not mid-recording. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Sendspark product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Tauri 2 with React and TypeScript, one desktop application, The display and media capture APIs inside the webview, with tracks verified after the fact, ffmpeg invoked locally for remux and trim, originals kept, SQLite plus a videos directory, no upload anywhere, A static page folder with a random identifier, no tracking of any kind. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. - Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. - Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. - Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. - A recorder with no storage ceiling fills a laptop disk quietly over three months, and the failure arrives as an unrelated crash in a different application while a recording is in progress. Check free space and the configured ceiling before starting, and refuse with a list of the largest recordings rather than starting and failing midway.
# Delivery milestones · Sendspark Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Prove you captured what you asked for Recording only starts once the tracks that came back are inspected and shown to the user. ### Steps 1. Inspect the returned tracks before starting The request is a suggestion; the tracks are the fact. Files: `src/capture/start.ts`, `src/capture/inspect.ts` 2. Show a live input meter and block on silence Two seconds of checking against forty minutes of silent video. Files: `src/components/InputMeter.tsx` 3. Record what was granted on the row Source kind, audio source, dimensions, at capture time. Files: `src/db/recordings.ts`, `tests/capture.test.ts` ### Done when - [ ] A missing audio track blocks the start with an explanation - [ ] Granted surface and audio source are shown before recording - [ ] The input meter reflects real microphone input - [ ] The row records what was actually granted ### Watch out - The documented behaviour is that the returned stream may contain no audio track even when audio was requested and system audio was asked to be included, and that the options you pass cannot limit what the user picks because they are applied after the choice is made. So a recorder that trusts its own request records forty minutes of silent demo and you find out when you open it. Inspect the tracks after the promise resolves, show a meter, and block the start on a silent input. ## M2 · A file that survives a crash and can be scrubbed Chunks land on disk as they arrive and the finished file is remuxed so players can seek it. ### Steps 1. Write chunks to disk as they arrive Memory is where recordings go to die at minute thirty nine. Files: `src/capture/chunks.ts` 2. Remux before calling a recording finished Otherwise the player cannot seek and reports no duration. Files: `src-tauri/src/encode.rs`, `tests/remux.test.ts` ```sh ffmpeg -i raw.webm -c copy -fflags +genpts fixed.webm ffprobe -v error -show_entries format=duration fixed.webm ``` 3. Recover orphaned chunks on launch The crash already happened; do not also lose the footage. Files: `src/capture/recover.ts` ### Done when - [ ] A killed recording leaves recoverable chunks - [ ] The finished file seeks correctly in another player - [ ] The raw concatenation is retained - [ ] A failed remux leaves the chunks in place ### Watch out - Buffering the whole recording in memory and writing it at the end means a crash at minute thirty nine loses the entire session, and the output from a browser recorder typically lacks the duration and index a player needs, so even a successful recording scrubs to nowhere and reports an unknown length. Write chunks as they arrive and always run a remux pass before the file is treated as finished. ## M3 · Trimming that does not touch the original Cuts are stored as ranges, exports are rendered from them, and the source file is never replaced. ### Steps 1. Store trims as ranges, never as edits Non-destructive is the whole reason the original is kept. Files: `src/trim/model.ts` 2. Snap to keyframes or re-encode, and say which An inaccurate cut presented as exact is the annoying kind of bug. Files: `src-tauri/src/encode.rs`, `tests/trim.test.ts` 3. Generate the poster from inside the trimmed range The first frame of the source is usually a desktop. Files: `src/trim/poster.ts` ### Done when - [ ] The source hash is unchanged after an export - [ ] The achieved start offset is reported - [ ] Frame-accurate mode re-encodes and is recorded - [ ] The poster frame is within the trimmed range ### Watch out - Cutting with a stream copy at an arbitrary timestamp silently starts the output at the previous keyframe, so the export begins a second and a half before the point you chose and includes the throat clearing you were removing. Either snap the cut to keyframes and show the user where it actually landed, or re-encode and say so, but never present an inaccurate cut as an exact one. ## M4 · A page for one person, not a directory for everyone Each recipient page has an unguessable identifier, an expiry and a revoke, and tracks nothing. ### Steps 1. Use a cryptographic random identifier Not a counter, not a hash of the recipient's email. Files: `src/pages/id.ts`, `tests/page-id.test.ts` 2. Emit a page with no third-party requests Fonts and analytics included, which means self-hosting both or neither. Files: `src/pages/render.ts` 3. Make revoke remove files, not flip a flag A flag on a static file server protects nothing. Files: `src/pages/revoke.ts` ### Done when - [ ] Identifiers are cryptographically random and non-derivable - [ ] Revoked and expired pages have no files on disk - [ ] The page makes no third-party request - [ ] Deleting a recording deletes its pages ### Watch out - Numbering pages sequentially or deriving the identifier from the recipient's name or email means one prospect can find another prospect's page by changing a character, and since these pages are usually personal sales messages the leak is the embarrassing kind. Use a long random identifier from a cryptographic source and never derive it from anything about the recipient. ## M5 · Disk, deletion and getting a video out (production only) Storage has a ceiling, old recordings expire, and any recording leaves as a self-contained folder. ### Steps 1. Check space and ceiling before capture starts Failing at minute nought is far better than at minute thirty. Files: `src/storage/guard.ts`, `tests/storage.test.ts` 2. Expire recordings with their pages and artefacts In one operation, with a deletion log. Files: `src/storage/retention.ts` 3. Export a folder that works on its own Video, poster, transcript, summary, metadata. Files: `src/export/bundle.ts` ### Done when - [ ] An over-ceiling recording is refused before capture - [ ] Retention removes recordings, pages and artefacts together - [ ] An exported folder works without the application - [ ] Reveal and copy-path work for every recording ### Watch out - A recorder with no storage ceiling fills a laptop disk quietly over three months, and the failure arrives as an unrelated crash in a different application while a recording is in progress. Check free space and the configured ceiling before starting, and refuse with a list of the largest recordings rather than starting and failing midway.
# Operations · Sendspark ## Backup The videos directory and the database together. Video is large and mostly disposable, but the trims and pages are metadata that cannot be recreated from the files alone. ## Restore Restore both, then open a recording and confirm the trim ranges still map to the right moments and that pages resolve. A restore that loses the database leaves a folder of anonymous video files. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Free space against the ceiling, remux failure rate, and the number of orphaned chunk directories. Orphans accumulating means the application is crashing during capture and nobody has mentioned it. ## Incident checklist The two incidents that matter are a lost recording and a page reaching the wrong person. Chunk recovery addresses the first, and random identifiers with a real revoke address the second. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A missing audio track is confirmed to block the start - [ ] A killed recording is confirmed recoverable from chunks - [ ] The finished file is confirmed to seek in another player - [ ] Page identifiers are confirmed cryptographically random and non-derivable - [ ] An over-ceiling recording is confirmed refused before capture ## Launch constraint Do not market omitted Sendspark capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Chunks, raw concatenations, remuxed files and exports. VIDEOS_DIR=./data/videos # Required. Recordings, trims, pages and artefacts. DATABASE_PATH=./data/recordings.db # Required. A new recording is refused above this, before capture starts. STORAGE_CEILING_GB=20 # Required. Checked alongside the ceiling so the disk is never filled to zero. FREE_SPACE_MARGIN_GB=5 # Required. Length of the random page identifier. Longer is cheap here. PAGE_ID_BYTES=16 # Required. Recipient pages expire and their files are removed. DEFAULT_EXPIRY_DAYS=30 # Required. Resolved once at startup so a missing binary fails loudly, not mid-recording. FFMPEG_PATH=/usr/bin/ffmpeg
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Sendspark because the recording itself is easy; the subscription removes upload, transcoding, sharing, organization, and support work. The recurring cost buys OS permissions, codecs, rendering performance, uploads, storage, links, and viewer analytics, not just the visible interface.
xpersonalization at scale, CRM workflows, hosting, and engagement tracking
xinstant hosted sharing
xviewer analytics
xteam libraries
xcross-device capture
Sendspark pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| solo | $49/workspace | $39/workspace | 1 seat; 5,000 stored videos; 100 dynamic-video minutes |
| growth | $99/workspace | $69/workspace | 3 seats; 20,000 stored videos; 250 dynamic-video minutes; 7,500 workflow tasks |
| team | $299/workspace | $199/workspace | 10 seats; 100,000 stored videos; 1,000 dynamic-video minutes; 25,000 workflow tasks |
| business | $699/workspace | $499/workspace | 25 seats; unlimited storage; 3,000 dynamic-video minutes; 75,000 workflow tasks |
| agency | custom | — | Custom client workspaces, seats, minutes, tasks and support |
| enterprise | custom | — | Custom deployment, security, integrations, seats and support |
free tierno free tier; 7-day trial without a card
billingmonthly + annual; annual subscriptions are non-refundable
hidden costsExtra seats cost $29–$49/month; dynamic-video overage costs $0.19–$0.39/minute and each extra 1,000 workflow tasks costs $2–$10 depending on plan.
verified 2026-08-14 · source ↗
Vibecode Sendspark
Kinda. The core of Sendspark is buildable in a weekend with the prompt on this page, but there are real gaps: personalization at scale, CRM workflows, hosting, and engagement tracking, instant hosted sharing. Read the honest list above before committing.
How much does Sendspark cost?
Sendspark costs about $49/month (Solo, checked 2026-08-14), which is $588 per year.
What do I lose by replacing Sendspark?
Honestly: personalization at scale, CRM workflows, hosting, and engagement tracking; instant hosted sharing; viewer analytics; team libraries; cross-device capture. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Sendspark?
Yes: Cap (Open-source screen recording and sharing product with modern desktop architecture.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.