Vibecode Kapwing
track this build4 phases, 12 steps, beginner friendly0%The visible collaborative video editor loop is buildable, but a credible replacement needs more than the first screen. Kapwing earns its keep through media pipeline, hosting, distribution, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Kapwing. 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 ===== # Kapwing · indie build A local editor whose hard problems are describing an edit in rational time so cuts land on frame boundaries, keeping the preview and the export on one filter graph so faults appear early, and producing a container that starts playing before it has finished downloading. Estimated effort: **multi-day**. 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 | Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess | FFmpeg is the renderer, and wrapping it carelessly is the main way a project like this goes wrong | | Edit model | A non-destructive edit list in SQLite, with source files opened read-only | an editor that has already written over your only copy of something is not an editor | | Times | Integers in the probed time base, never floating point seconds | frame boundaries do not land on decimal seconds and the error accumulates along the timeline | | Preview | Low resolution proxies generated on import, rendered through the same filter graph | a preview built by a second code path is a preview that lies to you | | Export | One graph, one encode, then a delivery pass for the container | chained encodes lose quality twice and hide which stage introduced the fault | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the interface, the queue and the FFmpeg wrapper. - Get it: python.org or your version manager. - Verify: python3 --version prints 3.12 or higher - [ ] **FFmpeg and its probe tool, recent build** · free - Why: It is the entire renderer, and an old build will be missing options this project assumes. - Get it: Your package manager or a static build from the project. - Verify: Both binaries run and report a version, and the mp4 muxer lists the faststart option - [ ] **A fixture set with a portrait phone clip and a screen recording** · free - Why: Rotation metadata and an irregular frame rate are the two inputs that break a naive editor, and neither appears in a clean test clip. - Get it: Record ten seconds on a phone held upright and ten seconds of your screen. - Verify: Both import, probe correctly and export without rotation or drift - [ ] **Free disk several times the size of your longest source** · free - Why: An intermediate render plus proxies plus the output can be several times the input, and a disk that fills mid-render takes the project directory with it. - Get it: Point the temporary directory at a volume with room and set the ceiling below its free space. - Verify: A render is refused when free space is below the configured figure ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Real-time collaborative editing. - Automatic subtitles. - Professional colour management and broadcast codecs. - Hosting and distribution. - distribution and podcast hosting - professional codecs, effects, and collaboration - high-fidelity color, format, and export handling - hosted recording reliability - large-scale rendering If one of those is essential to you, that is the reason to keep paying for Kapwing, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Kapwing 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 local video editing workspace that never writes to your source files and renders one reproducible export to replace Kapwing. 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: Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess · FFmpeg is the renderer, and wrapping it carelessly is the main way a project like this goes wrong - Edit model: A non-destructive edit list in SQLite, with source files opened read-only · an editor that has already written over your only copy of something is not an editor - Times: Integers in the probed time base, never floating point seconds · frame boundaries do not land on decimal seconds and the error accumulates along the timeline - Preview: Low resolution proxies generated on import, rendered through the same filter graph · a preview built by a second code path is a preview that lies to you - Export: One graph, one encode, then a delivery pass for the container · chained encodes lose quality twice and hide which stage introduced the fault ### Data model (create this before Phase 1) - media(id, path, sha256, probe_json, duration_ms, video_codec, audio_codec, time_base, avg_fps, is_variable_fps, rotation, sample_rate, channels, imported_at) - proxies(media_id, path, width, height, generated_at) · regenerable, and never an input to an export - projects(id, name, width, height, fps_num, fps_den, sample_rate, created_at) · the frame rate is a fraction because many real ones are not integers - clips(id, project_id, track, media_id, source_in, source_out, timeline_in, ordinal, transform_json) · times as integers in the project time base - renders(id, project_id, preset_id, started_at, finished_at, state, command_text, stderr_tail, output_path, expected_duration_ms, actual_duration_ms) - presets(id, name, container, video_args_json, audio_args_json, delivery_pass) · the arguments live in data so an old render can be reproduced exactly - Nothing in this schema writes to a source file. The edit list plus the probe record is enough to reproduce any export, which is what makes a bad render debuggable a week later. ### Phase 1 · Probe everything, trust nothing Build: Run a probe on every import and store the output verbatim alongside the extracted fields: codecs, time base, average and reported frame rate, whether the frame rate is variable, rotation, sample rate and channel layout. Hash the file. Refuse an import that does not probe, showing the error. Trap: A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. Done when: A portrait clip recorded on a phone exports upright, tested against a fixture. The probe output is stored verbatim and the extracted fields match it, tested. A file that fails to probe is refused with the error shown rather than imported as a broken row. Re-importing the same file is detected by hash and does not create a second media row. Do not build yet: No timeline, preview, export or queue yet. ### Phase 2 · An edit list, and sources opened read-only Build: Represent every clip as a source range and a timeline position in integers in the project time base. Convert on entry from whatever the interface produced. Open source files read-only and verify the stored hash before a render. Serialise the whole project to JSON and back. Trap: Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. Done when: Splitting a clip and rejoining the halves reproduces the original duration exactly, tested. No timeline or source value is stored as a floating point number, verified by schema review and a test. Source file modification times and hashes are unchanged after a full render, asserted in the suite. A project serialises to JSON and reloads to an identical edit list, tested. Do not build yet: No preview, proxies, export or queue yet. ### Phase 3 · A preview that is the export, only smaller Build: Generate low resolution proxies on import. Build the filter graph from a single function that takes a scale and an output target. Render the preview from the proxies through that graph. Never let a proxy become an input to an export. Trap: Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. Done when: A test asserts the preview and export graphs differ only in scale and output arguments. Deleting every proxy degrades the preview and changes no export output, tested. Proxies are regenerated from source and never read during an export, asserted by inspecting the render command. Scrubbing a timeline of a stated length stays responsive on the development machine, measured and recorded. Do not build yet: No delivery container work, render queue or disk limits yet. ### Phase 4 · A file that starts playing before it has finished downloading Build: Render the timeline as a single pass through one graph. For delivery, add the option that moves the index to the front of the container. Compare the rendered duration against the timeline and fail the render if they differ by more than a frame. Store the exact command and the error tail. Trap: Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. Done when: A delivery export has its index at the front, confirmed by inspecting the file rather than by trusting the flag. A render whose output duration differs from the timeline by more than one frame is marked failed and says by how much, tested by forcing a mismatch. The exact command and the last lines of the error output are stored on the render row and shown in the interface. A failed render leaves no file in the output directory. Do not build yet: No queue, cancellation, disk ceiling or project export yet. ### Phase 5 · Disk, cancellation, and jobs that outlive the tab Build: Run one render at a time from a queue that survives a page reload. Cancellation kills the process and removes the partial output. Check free disk before starting and while running, and stop with a figure rather than an error. Sweep temporary files by age. Export the project as JSON plus a manifest of source hashes so it can be reopened elsewhere against the same files. Trap: Assembling an export by rendering each clip separately and joining them with the concat demuxer looks efficient and drifts the audio of everything after the first slightly wrong duration, because the documentation requires that 'all files must have the same streams (same codecs, same time base, etc.)' and adjusts each file's timestamps using its stored duration, which it also says 'can cause artifacts' when that duration is incorrect. Checked at ffmpeg.org on 15 September 2026. Render the timeline as one graph and keep concat for parts you produced yourself with identical settings. Done when: Cancelling a render kills the process, removes the partial output and leaves no orphan, tested by cancelling mid-render. Starting a render with less than the configured free disk is refused with the actual figure in the message. The temporary sweep removes files past a stated age and never touches a running render's files, tested with a live job. A project exported with its source manifest opens on another machine with the same files and re-renders to the same duration. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaborative editing, which needs a shared document model and conflict handling that is a larger project than the editor itself. - Automatic subtitles, which are a separate accuracy claim and are not worth shipping without a correction interface in front of them. - Professional colour management and broadcast codecs, which need calibrated hardware and licences before any of it is verifiable. - Hosting and distribution of finished videos, which is storage, bandwidth and takedown handling rather than an editor feature. ### README must contain - That source files are opened read-only and that the suite asserts their hashes are unchanged after a render - That timeline values are integers in the project time base, with the split-and-rejoin test named as the reason - That the preview and the export come from one filter graph, and what the parity test checks - That the delivery preset moves the container index to the front, quoting the FFmpeg documentation with the date checked, and what that costs in render time - Where temporary files live, how much free disk a render needs, and what cancellation cleans up ===== AGENTS.md ===== # Agent instructions · Kapwing indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess, A non-destructive edit list in SQLite, with source files opened read-only, Integers in the probed time base, never floating point seconds, Low resolution proxies generated on import, rendered through the same filter graph, One graph, one encode, then a delivery pass for the container. 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 - A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. - Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. - Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. - Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. ===== BUILD_PLAN.md ===== # Build plan · Kapwing A local editor whose hard problems are describing an edit in rational time so cuts land on frame boundaries, keeping the preview and the export on one filter graph so faults appear early, and producing a container that starts playing before it has finished downloading. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Probe everything, trust nothing Import records what a file actually is, and refuses anything it cannot describe. ### Steps 1. Store the probe output verbatim as well as the parsed fields The parse will be wrong about something and you will want the original. Files: `src/media/probe.py`, `tests/test_probe.py` 2. Normalise rotation in the graph, not in the player The player was lying to you comfortably. Files: `src/render/graph.py`, `tests/fixtures/portrait.mp4` 3. Refuse what you cannot describe A media row with unknown fields poisons every render that touches it. Files: `src/media/import.py` ### Done when - [ ] A portrait phone clip exports upright - [ ] Probe output is stored verbatim and matches the parsed fields - [ ] An unprobeable file is refused with its error - [ ] Re-import is detected by hash ### Watch out - A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. ## Phase 2 · An edit list, and sources opened read-only Trimming, splitting and reordering change rows only, and the source files are provably untouched. ### Steps 1. Convert to the project time base at the edge Nothing past the edge should ever see a decimal second. Files: `src/timeline/units.py`, `tests/test_units.py` 2. Open sources read-only and verify the hash Both, because read-only is a habit and the hash is the proof. Files: `src/media/open.py`, `tests/test_untouched.py` 3. Make the project serialise and reload identically It is the cheapest test of whether the model is complete. Files: `src/project/serialise.py` ### Done when - [ ] Split and rejoin reproduces the exact duration - [ ] No time value is stored as a float - [ ] Source files are unchanged after a render - [ ] Project JSON round-trips identically ### Watch out - Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. ## Phase 3 · A preview that is the export, only smaller One function builds the filter graph and both the preview and the export use it. ### Steps 1. Build both graphs from one function Two graphs is two products with one name. Files: `src/render/graph.py`, `tests/test_graph_parity.py` 2. Render the preview from proxies, the export from sources And assert the export command never mentions a proxy path. Files: `src/render/preview.py`, `src/render/export.py` 3. Regenerate proxies rather than protecting them They are cache, and treating them as cache keeps them honest. Files: `src/media/proxy.py` ### Done when - [ ] Preview and export graphs differ only in scale and output - [ ] Deleting proxies changes no export - [ ] No export command references a proxy - [ ] Scrubbing is responsive at a stated timeline length ### Watch out - Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. ## Phase 4 · A file that starts playing before it has finished downloading The delivery export carries its index at the front and its duration is checked, not assumed. ### Steps 1. Add the delivery pass to the preset, in data not in code So an old render's arguments can be read back exactly. Files: `src/presets.py`, `data/presets.json` 2. Compare rendered duration against the timeline A silent duration drift is the fault you find on upload day. Files: `src/render/verify.py`, `tests/test_duration.py` 3. Store the command and the error tail on the render row A render you cannot reproduce is a render you cannot fix. Files: `src/render/run.py` ### Done when - [ ] The delivery file carries its index at the front - [ ] A duration mismatch fails the render and says by how much - [ ] Command text and error tail are stored per render - [ ] A failed render leaves no output file ### Watch out - Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. ## Not in this build - Real-time collaborative editing. - Automatic subtitles. - Professional colour management and broadcast codecs. - Hosting and distribution. ## After v1, if you want it - A waveform view generated from the proxy audio, which makes cutting on speech possible - Render presets per destination stored as data, so a new target is a row rather than a branch - A watch folder that probes and imports automatically, refusing anything it cannot describe ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Media records, projects, edit lists and render history. DATABASE_PATH=./data/editor.db # Required. Where imported files are referenced from. They are never written to. MEDIA_DIR=./data/media # Required. Low resolution previews. Safe to delete at any time. PROXY_DIR=./data/proxies # Required. Render intermediates. Swept by age, and never on the same volume as your only copy of anything. TEMP_DIR=./data/tmp # Required. Finished renders. A failed render leaves nothing here. OUTPUT_DIR=./data/exports # Required. A render below this is refused with the actual figure. MIN_FREE_DISK_GB=20 # Required. Preview resolution. Lower makes scrubbing quicker and changes nothing about exports. PROXY_HEIGHT=360 # Required. Recorded on every render row, because a version change explains a different output. FFMPEG_PATH=/usr/bin/ffmpeg
You are building a lean indie version of Kapwing. 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 ===== # Kapwing · indie build A local editor whose hard problems are describing an edit in rational time so cuts land on frame boundaries, keeping the preview and the export on one filter graph so faults appear early, and producing a container that starts playing before it has finished downloading. Estimated effort: **multi-day**. 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 | Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess | FFmpeg is the renderer, and wrapping it carelessly is the main way a project like this goes wrong | | Edit model | A non-destructive edit list in SQLite, with source files opened read-only | an editor that has already written over your only copy of something is not an editor | | Times | Integers in the probed time base, never floating point seconds | frame boundaries do not land on decimal seconds and the error accumulates along the timeline | | Preview | Low resolution proxies generated on import, rendered through the same filter graph | a preview built by a second code path is a preview that lies to you | | Export | One graph, one encode, then a delivery pass for the container | chained encodes lose quality twice and hide which stage introduced the fault | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the interface, the queue and the FFmpeg wrapper. - Get it: python.org or your version manager. - Verify: python3 --version prints 3.12 or higher - [ ] **FFmpeg and its probe tool, recent build** · free - Why: It is the entire renderer, and an old build will be missing options this project assumes. - Get it: Your package manager or a static build from the project. - Verify: Both binaries run and report a version, and the mp4 muxer lists the faststart option - [ ] **A fixture set with a portrait phone clip and a screen recording** · free - Why: Rotation metadata and an irregular frame rate are the two inputs that break a naive editor, and neither appears in a clean test clip. - Get it: Record ten seconds on a phone held upright and ten seconds of your screen. - Verify: Both import, probe correctly and export without rotation or drift - [ ] **Free disk several times the size of your longest source** · free - Why: An intermediate render plus proxies plus the output can be several times the input, and a disk that fills mid-render takes the project directory with it. - Get it: Point the temporary directory at a volume with room and set the ceiling below its free space. - Verify: A render is refused when free space is below the configured figure ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Real-time collaborative editing. - Automatic subtitles. - Professional colour management and broadcast codecs. - Hosting and distribution. - distribution and podcast hosting - professional codecs, effects, and collaboration - high-fidelity color, format, and export handling - hosted recording reliability - large-scale rendering If one of those is essential to you, that is the reason to keep paying for Kapwing, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Kapwing 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 local video editing workspace that never writes to your source files and renders one reproducible export to replace Kapwing. 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: Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess · FFmpeg is the renderer, and wrapping it carelessly is the main way a project like this goes wrong - Edit model: A non-destructive edit list in SQLite, with source files opened read-only · an editor that has already written over your only copy of something is not an editor - Times: Integers in the probed time base, never floating point seconds · frame boundaries do not land on decimal seconds and the error accumulates along the timeline - Preview: Low resolution proxies generated on import, rendered through the same filter graph · a preview built by a second code path is a preview that lies to you - Export: One graph, one encode, then a delivery pass for the container · chained encodes lose quality twice and hide which stage introduced the fault ### Data model (create this before Phase 1) - media(id, path, sha256, probe_json, duration_ms, video_codec, audio_codec, time_base, avg_fps, is_variable_fps, rotation, sample_rate, channels, imported_at) - proxies(media_id, path, width, height, generated_at) · regenerable, and never an input to an export - projects(id, name, width, height, fps_num, fps_den, sample_rate, created_at) · the frame rate is a fraction because many real ones are not integers - clips(id, project_id, track, media_id, source_in, source_out, timeline_in, ordinal, transform_json) · times as integers in the project time base - renders(id, project_id, preset_id, started_at, finished_at, state, command_text, stderr_tail, output_path, expected_duration_ms, actual_duration_ms) - presets(id, name, container, video_args_json, audio_args_json, delivery_pass) · the arguments live in data so an old render can be reproduced exactly - Nothing in this schema writes to a source file. The edit list plus the probe record is enough to reproduce any export, which is what makes a bad render debuggable a week later. ### Phase 1 · Probe everything, trust nothing Build: Run a probe on every import and store the output verbatim alongside the extracted fields: codecs, time base, average and reported frame rate, whether the frame rate is variable, rotation, sample rate and channel layout. Hash the file. Refuse an import that does not probe, showing the error. Trap: A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. Done when: A portrait clip recorded on a phone exports upright, tested against a fixture. The probe output is stored verbatim and the extracted fields match it, tested. A file that fails to probe is refused with the error shown rather than imported as a broken row. Re-importing the same file is detected by hash and does not create a second media row. Do not build yet: No timeline, preview, export or queue yet. ### Phase 2 · An edit list, and sources opened read-only Build: Represent every clip as a source range and a timeline position in integers in the project time base. Convert on entry from whatever the interface produced. Open source files read-only and verify the stored hash before a render. Serialise the whole project to JSON and back. Trap: Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. Done when: Splitting a clip and rejoining the halves reproduces the original duration exactly, tested. No timeline or source value is stored as a floating point number, verified by schema review and a test. Source file modification times and hashes are unchanged after a full render, asserted in the suite. A project serialises to JSON and reloads to an identical edit list, tested. Do not build yet: No preview, proxies, export or queue yet. ### Phase 3 · A preview that is the export, only smaller Build: Generate low resolution proxies on import. Build the filter graph from a single function that takes a scale and an output target. Render the preview from the proxies through that graph. Never let a proxy become an input to an export. Trap: Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. Done when: A test asserts the preview and export graphs differ only in scale and output arguments. Deleting every proxy degrades the preview and changes no export output, tested. Proxies are regenerated from source and never read during an export, asserted by inspecting the render command. Scrubbing a timeline of a stated length stays responsive on the development machine, measured and recorded. Do not build yet: No delivery container work, render queue or disk limits yet. ### Phase 4 · A file that starts playing before it has finished downloading Build: Render the timeline as a single pass through one graph. For delivery, add the option that moves the index to the front of the container. Compare the rendered duration against the timeline and fail the render if they differ by more than a frame. Store the exact command and the error tail. Trap: Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. Done when: A delivery export has its index at the front, confirmed by inspecting the file rather than by trusting the flag. A render whose output duration differs from the timeline by more than one frame is marked failed and says by how much, tested by forcing a mismatch. The exact command and the last lines of the error output are stored on the render row and shown in the interface. A failed render leaves no file in the output directory. Do not build yet: No queue, cancellation, disk ceiling or project export yet. ### Phase 5 · Disk, cancellation, and jobs that outlive the tab Build: Run one render at a time from a queue that survives a page reload. Cancellation kills the process and removes the partial output. Check free disk before starting and while running, and stop with a figure rather than an error. Sweep temporary files by age. Export the project as JSON plus a manifest of source hashes so it can be reopened elsewhere against the same files. Trap: Assembling an export by rendering each clip separately and joining them with the concat demuxer looks efficient and drifts the audio of everything after the first slightly wrong duration, because the documentation requires that 'all files must have the same streams (same codecs, same time base, etc.)' and adjusts each file's timestamps using its stored duration, which it also says 'can cause artifacts' when that duration is incorrect. Checked at ffmpeg.org on 15 September 2026. Render the timeline as one graph and keep concat for parts you produced yourself with identical settings. Done when: Cancelling a render kills the process, removes the partial output and leaves no orphan, tested by cancelling mid-render. Starting a render with less than the configured free disk is refused with the actual figure in the message. The temporary sweep removes files past a stated age and never touches a running render's files, tested with a live job. A project exported with its source manifest opens on another machine with the same files and re-renders to the same duration. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaborative editing, which needs a shared document model and conflict handling that is a larger project than the editor itself. - Automatic subtitles, which are a separate accuracy claim and are not worth shipping without a correction interface in front of them. - Professional colour management and broadcast codecs, which need calibrated hardware and licences before any of it is verifiable. - Hosting and distribution of finished videos, which is storage, bandwidth and takedown handling rather than an editor feature. ### README must contain - That source files are opened read-only and that the suite asserts their hashes are unchanged after a render - That timeline values are integers in the project time base, with the split-and-rejoin test named as the reason - That the preview and the export come from one filter graph, and what the parity test checks - That the delivery preset moves the container index to the front, quoting the FFmpeg documentation with the date checked, and what that costs in render time - Where temporary files live, how much free disk a render needs, and what cancellation cleans up ===== AGENTS.md ===== # Agent instructions · Kapwing indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess, A non-destructive edit list in SQLite, with source files opened read-only, Integers in the probed time base, never floating point seconds, Low resolution proxies generated on import, rendered through the same filter graph, One graph, one encode, then a delivery pass for the container. 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 - A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. - Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. - Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. - Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. ===== BUILD_PLAN.md ===== # Build plan · Kapwing A local editor whose hard problems are describing an edit in rational time so cuts land on frame boundaries, keeping the preview and the export on one filter graph so faults appear early, and producing a container that starts playing before it has finished downloading. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Probe everything, trust nothing Import records what a file actually is, and refuses anything it cannot describe. ### Steps 1. Store the probe output verbatim as well as the parsed fields The parse will be wrong about something and you will want the original. Files: `src/media/probe.py`, `tests/test_probe.py` 2. Normalise rotation in the graph, not in the player The player was lying to you comfortably. Files: `src/render/graph.py`, `tests/fixtures/portrait.mp4` 3. Refuse what you cannot describe A media row with unknown fields poisons every render that touches it. Files: `src/media/import.py` ### Done when - [ ] A portrait phone clip exports upright - [ ] Probe output is stored verbatim and matches the parsed fields - [ ] An unprobeable file is refused with its error - [ ] Re-import is detected by hash ### Watch out - A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. ## Phase 2 · An edit list, and sources opened read-only Trimming, splitting and reordering change rows only, and the source files are provably untouched. ### Steps 1. Convert to the project time base at the edge Nothing past the edge should ever see a decimal second. Files: `src/timeline/units.py`, `tests/test_units.py` 2. Open sources read-only and verify the hash Both, because read-only is a habit and the hash is the proof. Files: `src/media/open.py`, `tests/test_untouched.py` 3. Make the project serialise and reload identically It is the cheapest test of whether the model is complete. Files: `src/project/serialise.py` ### Done when - [ ] Split and rejoin reproduces the exact duration - [ ] No time value is stored as a float - [ ] Source files are unchanged after a render - [ ] Project JSON round-trips identically ### Watch out - Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. ## Phase 3 · A preview that is the export, only smaller One function builds the filter graph and both the preview and the export use it. ### Steps 1. Build both graphs from one function Two graphs is two products with one name. Files: `src/render/graph.py`, `tests/test_graph_parity.py` 2. Render the preview from proxies, the export from sources And assert the export command never mentions a proxy path. Files: `src/render/preview.py`, `src/render/export.py` 3. Regenerate proxies rather than protecting them They are cache, and treating them as cache keeps them honest. Files: `src/media/proxy.py` ### Done when - [ ] Preview and export graphs differ only in scale and output - [ ] Deleting proxies changes no export - [ ] No export command references a proxy - [ ] Scrubbing is responsive at a stated timeline length ### Watch out - Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. ## Phase 4 · A file that starts playing before it has finished downloading The delivery export carries its index at the front and its duration is checked, not assumed. ### Steps 1. Add the delivery pass to the preset, in data not in code So an old render's arguments can be read back exactly. Files: `src/presets.py`, `data/presets.json` 2. Compare rendered duration against the timeline A silent duration drift is the fault you find on upload day. Files: `src/render/verify.py`, `tests/test_duration.py` 3. Store the command and the error tail on the render row A render you cannot reproduce is a render you cannot fix. Files: `src/render/run.py` ### Done when - [ ] The delivery file carries its index at the front - [ ] A duration mismatch fails the render and says by how much - [ ] Command text and error tail are stored per render - [ ] A failed render leaves no output file ### Watch out - Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. ## Not in this build - Real-time collaborative editing. - Automatic subtitles. - Professional colour management and broadcast codecs. - Hosting and distribution. ## After v1, if you want it - A waveform view generated from the proxy audio, which makes cutting on speech possible - Render presets per destination stored as data, so a new target is a row rather than a branch - A watch folder that probes and imports automatically, refusing anything it cannot describe ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Media records, projects, edit lists and render history. DATABASE_PATH=./data/editor.db # Required. Where imported files are referenced from. They are never written to. MEDIA_DIR=./data/media # Required. Low resolution previews. Safe to delete at any time. PROXY_DIR=./data/proxies # Required. Render intermediates. Swept by age, and never on the same volume as your only copy of anything. TEMP_DIR=./data/tmp # Required. Finished renders. A failed render leaves nothing here. OUTPUT_DIR=./data/exports # Required. A render below this is refused with the actual figure. MIN_FREE_DISK_GB=20 # Required. Preview resolution. Lower makes scrubbing quicker and changes nothing about exports. PROXY_HEIGHT=360 # Required. Recorded on every render row, because a version change explains a different output. FFMPEG_PATH=/usr/bin/ffmpeg
You are building a production product version of Kapwing. 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 ===== # Kapwing · product brief ## Problem The visible collaborative video editor loop is buildable, but a credible replacement needs more than the first screen. Kapwing earns its keep through media pipeline, hosting, distribution, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome An editor that leaves your source files exactly as it found them, cuts on frame boundaries because the timeline is integers, shows you faults in the preview rather than at the end of a long render, and produces a file that starts playing over the web. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Python 3.12 - FFmpeg - desktop with sufficient storage - optional model or API key in .env ## Explicit non-goals for v1 - Real-time collaborative editing. - Automatic subtitles. - Professional colour management and broadcast codecs. - Hosting and distribution. - distribution and podcast hosting - professional codecs, effects, and collaboration - high-fidelity color, format, and export handling - hosted recording reliability - large-scale rendering ## Success criteria - Source file hashes are confirmed unchanged after a full render - Split and rejoin is confirmed to reproduce the original duration exactly - Preview and export graphs are confirmed to differ only in scale and output - A delivery export is confirmed to carry its index at the front - Cancellation is confirmed to leave no partial output and no orphan process ===== BRIEF.md ===== # Build brief · Kapwing 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 local video editing workspace that never writes to your source files and renders one reproducible export to replace Kapwing. 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: Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess · FFmpeg is the renderer, and wrapping it carelessly is the main way a project like this goes wrong - Edit model: A non-destructive edit list in SQLite, with source files opened read-only · an editor that has already written over your only copy of something is not an editor - Times: Integers in the probed time base, never floating point seconds · frame boundaries do not land on decimal seconds and the error accumulates along the timeline - Preview: Low resolution proxies generated on import, rendered through the same filter graph · a preview built by a second code path is a preview that lies to you - Export: One graph, one encode, then a delivery pass for the container · chained encodes lose quality twice and hide which stage introduced the fault ### Data model (create this before Phase 1) - media(id, path, sha256, probe_json, duration_ms, video_codec, audio_codec, time_base, avg_fps, is_variable_fps, rotation, sample_rate, channels, imported_at) - proxies(media_id, path, width, height, generated_at) · regenerable, and never an input to an export - projects(id, name, width, height, fps_num, fps_den, sample_rate, created_at) · the frame rate is a fraction because many real ones are not integers - clips(id, project_id, track, media_id, source_in, source_out, timeline_in, ordinal, transform_json) · times as integers in the project time base - renders(id, project_id, preset_id, started_at, finished_at, state, command_text, stderr_tail, output_path, expected_duration_ms, actual_duration_ms) - presets(id, name, container, video_args_json, audio_args_json, delivery_pass) · the arguments live in data so an old render can be reproduced exactly - Nothing in this schema writes to a source file. The edit list plus the probe record is enough to reproduce any export, which is what makes a bad render debuggable a week later. ### Phase 1 · Probe everything, trust nothing Build: Run a probe on every import and store the output verbatim alongside the extracted fields: codecs, time base, average and reported frame rate, whether the frame rate is variable, rotation, sample rate and channel layout. Hash the file. Refuse an import that does not probe, showing the error. Trap: A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. Done when: A portrait clip recorded on a phone exports upright, tested against a fixture. The probe output is stored verbatim and the extracted fields match it, tested. A file that fails to probe is refused with the error shown rather than imported as a broken row. Re-importing the same file is detected by hash and does not create a second media row. Do not build yet: No timeline, preview, export or queue yet. ### Phase 2 · An edit list, and sources opened read-only Build: Represent every clip as a source range and a timeline position in integers in the project time base. Convert on entry from whatever the interface produced. Open source files read-only and verify the stored hash before a render. Serialise the whole project to JSON and back. Trap: Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. Done when: Splitting a clip and rejoining the halves reproduces the original duration exactly, tested. No timeline or source value is stored as a floating point number, verified by schema review and a test. Source file modification times and hashes are unchanged after a full render, asserted in the suite. A project serialises to JSON and reloads to an identical edit list, tested. Do not build yet: No preview, proxies, export or queue yet. ### Phase 3 · A preview that is the export, only smaller Build: Generate low resolution proxies on import. Build the filter graph from a single function that takes a scale and an output target. Render the preview from the proxies through that graph. Never let a proxy become an input to an export. Trap: Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. Done when: A test asserts the preview and export graphs differ only in scale and output arguments. Deleting every proxy degrades the preview and changes no export output, tested. Proxies are regenerated from source and never read during an export, asserted by inspecting the render command. Scrubbing a timeline of a stated length stays responsive on the development machine, measured and recorded. Do not build yet: No delivery container work, render queue or disk limits yet. ### Phase 4 · A file that starts playing before it has finished downloading Build: Render the timeline as a single pass through one graph. For delivery, add the option that moves the index to the front of the container. Compare the rendered duration against the timeline and fail the render if they differ by more than a frame. Store the exact command and the error tail. Trap: Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. Done when: A delivery export has its index at the front, confirmed by inspecting the file rather than by trusting the flag. A render whose output duration differs from the timeline by more than one frame is marked failed and says by how much, tested by forcing a mismatch. The exact command and the last lines of the error output are stored on the render row and shown in the interface. A failed render leaves no file in the output directory. Do not build yet: No queue, cancellation, disk ceiling or project export yet. ### Phase 5 · Disk, cancellation, and jobs that outlive the tab Build: Run one render at a time from a queue that survives a page reload. Cancellation kills the process and removes the partial output. Check free disk before starting and while running, and stop with a figure rather than an error. Sweep temporary files by age. Export the project as JSON plus a manifest of source hashes so it can be reopened elsewhere against the same files. Trap: Assembling an export by rendering each clip separately and joining them with the concat demuxer looks efficient and drifts the audio of everything after the first slightly wrong duration, because the documentation requires that 'all files must have the same streams (same codecs, same time base, etc.)' and adjusts each file's timestamps using its stored duration, which it also says 'can cause artifacts' when that duration is incorrect. Checked at ffmpeg.org on 15 September 2026. Render the timeline as one graph and keep concat for parts you produced yourself with identical settings. Done when: Cancelling a render kills the process, removes the partial output and leaves no orphan, tested by cancelling mid-render. Starting a render with less than the configured free disk is refused with the actual figure in the message. The temporary sweep removes files past a stated age and never touches a running render's files, tested with a live job. A project exported with its source manifest opens on another machine with the same files and re-renders to the same duration. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaborative editing, which needs a shared document model and conflict handling that is a larger project than the editor itself. - Automatic subtitles, which are a separate accuracy claim and are not worth shipping without a correction interface in front of them. - Professional colour management and broadcast codecs, which need calibrated hardware and licences before any of it is verifiable. - Hosting and distribution of finished videos, which is storage, bandwidth and takedown handling rather than an editor feature. ### README must contain - That source files are opened read-only and that the suite asserts their hashes are unchanged after a render - That timeline values are integers in the project time base, with the split-and-rejoin test named as the reason - That the preview and the export come from one filter graph, and what the parity test checks - That the delivery preset moves the container index to the front, quoting the FFmpeg documentation with the date checked, and what that costs in render time - Where temporary files live, how much free disk a render needs, and what cancellation cleans up ===== ARCHITECTURE.md ===== # Architecture · Kapwing ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess | FFmpeg is the renderer, and wrapping it carelessly is the main way a project like this goes wrong | | Edit model | A non-destructive edit list in SQLite, with source files opened read-only | an editor that has already written over your only copy of something is not an editor | | Times | Integers in the probed time base, never floating point seconds | frame boundaries do not land on decimal seconds and the error accumulates along the timeline | | Preview | Low resolution proxies generated on import, rendered through the same filter graph | a preview built by a second code path is a preview that lies to you | | Export | One graph, one encode, then a delivery pass for the container | chained encodes lose quality twice and hide which stage introduced the fault | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Media | Probe, hashing, rotation and refusal. | Any prober, provided its raw output is stored alongside the parsed fields. | | Timeline | Clips, ranges and the time base. | Any representation that is integers in a stated time base. Never seconds as floats. | | Graph | The one filter graph builder. | Not swappable for two builders, which is the failure this module exists to prevent. | | Render | Execution, duration verification and history. | Any runner, provided the command and error tail are stored. | | Queue | One job at a time, cancellation and disk limits. | Any queue that outlives the browser tab. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Media records, projects, edit lists and render history. - `MEDIA_DIR` · required · Where imported files are referenced from. They are never written to. - `PROXY_DIR` · required · Low resolution previews. Safe to delete at any time. - `TEMP_DIR` · required · Render intermediates. Swept by age, and never on the same volume as your only copy of anything. - `OUTPUT_DIR` · required · Finished renders. A failed render leaves nothing here. - `MIN_FREE_DISK_GB` · required · A render below this is refused with the actual figure. - `PROXY_HEIGHT` · required · Preview resolution. Lower makes scrubbing quicker and changes nothing about exports. - `FFMPEG_PATH` · required · Recorded on every render row, because a version change explains a different output. ## 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 · Kapwing product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess, A non-destructive edit list in SQLite, with source files opened read-only, Integers in the probed time base, never floating point seconds, Low resolution proxies generated on import, rendered through the same filter graph, One graph, one encode, then a delivery pass for the container. - 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 - A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. - Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. - Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. - Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. - Assembling an export by rendering each clip separately and joining them with the concat demuxer looks efficient and drifts the audio of everything after the first slightly wrong duration, because the documentation requires that 'all files must have the same streams (same codecs, same time base, etc.)' and adjusts each file's timestamps using its stored duration, which it also says 'can cause artifacts' when that duration is incorrect. Checked at ffmpeg.org on 15 September 2026. Render the timeline as one graph and keep concat for parts you produced yourself with identical settings. ===== MILESTONES.md ===== # Delivery milestones · Kapwing Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Probe everything, trust nothing Import records what a file actually is, and refuses anything it cannot describe. ### Steps 1. Store the probe output verbatim as well as the parsed fields The parse will be wrong about something and you will want the original. Files: `src/media/probe.py`, `tests/test_probe.py` 2. Normalise rotation in the graph, not in the player The player was lying to you comfortably. Files: `src/render/graph.py`, `tests/fixtures/portrait.mp4` 3. Refuse what you cannot describe A media row with unknown fields poisons every render that touches it. Files: `src/media/import.py` ### Done when - [ ] A portrait phone clip exports upright - [ ] Probe output is stored verbatim and matches the parsed fields - [ ] An unprobeable file is refused with its error - [ ] Re-import is detected by hash ### Watch out - A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. ## M2 · An edit list, and sources opened read-only Trimming, splitting and reordering change rows only, and the source files are provably untouched. ### Steps 1. Convert to the project time base at the edge Nothing past the edge should ever see a decimal second. Files: `src/timeline/units.py`, `tests/test_units.py` 2. Open sources read-only and verify the hash Both, because read-only is a habit and the hash is the proof. Files: `src/media/open.py`, `tests/test_untouched.py` 3. Make the project serialise and reload identically It is the cheapest test of whether the model is complete. Files: `src/project/serialise.py` ### Done when - [ ] Split and rejoin reproduces the exact duration - [ ] No time value is stored as a float - [ ] Source files are unchanged after a render - [ ] Project JSON round-trips identically ### Watch out - Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. ## M3 · A preview that is the export, only smaller One function builds the filter graph and both the preview and the export use it. ### Steps 1. Build both graphs from one function Two graphs is two products with one name. Files: `src/render/graph.py`, `tests/test_graph_parity.py` 2. Render the preview from proxies, the export from sources And assert the export command never mentions a proxy path. Files: `src/render/preview.py`, `src/render/export.py` 3. Regenerate proxies rather than protecting them They are cache, and treating them as cache keeps them honest. Files: `src/media/proxy.py` ### Done when - [ ] Preview and export graphs differ only in scale and output - [ ] Deleting proxies changes no export - [ ] No export command references a proxy - [ ] Scrubbing is responsive at a stated timeline length ### Watch out - Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. ## M4 · A file that starts playing before it has finished downloading The delivery export carries its index at the front and its duration is checked, not assumed. ### Steps 1. Add the delivery pass to the preset, in data not in code So an old render's arguments can be read back exactly. Files: `src/presets.py`, `data/presets.json` 2. Compare rendered duration against the timeline A silent duration drift is the fault you find on upload day. Files: `src/render/verify.py`, `tests/test_duration.py` 3. Store the command and the error tail on the render row A render you cannot reproduce is a render you cannot fix. Files: `src/render/run.py` ### Done when - [ ] The delivery file carries its index at the front - [ ] A duration mismatch fails the render and says by how much - [ ] Command text and error tail are stored per render - [ ] A failed render leaves no output file ### Watch out - Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. ## M5 · Disk, cancellation, and jobs that outlive the tab (production only) A long render can be cancelled cleanly, cannot fill the disk, and the project can move machines. ### Steps 1. Queue one render at a time and survive a reload The browser tab is not where the job should live. Files: `src/queue.py`, `tests/test_queue.py` 2. Check disk before and during, and stop with the number A full disk mid-render corrupts the output and the project directory. Files: `src/render/disk.py` 3. Export the project with a manifest of source hashes So reopening it elsewhere fails loudly on a missing file. Files: `src/project/export.py`, `tests/test_portable.py` ### Done when - [ ] Cancellation leaves no partial file or orphan process - [ ] A low disk start is refused with the figure - [ ] The temp sweep spares a running job's files - [ ] A project plus manifest reopens and re-renders identically ### Watch out - Assembling an export by rendering each clip separately and joining them with the concat demuxer looks efficient and drifts the audio of everything after the first slightly wrong duration, because the documentation requires that 'all files must have the same streams (same codecs, same time base, etc.)' and adjusts each file's timestamps using its stored duration, which it also says 'can cause artifacts' when that duration is incorrect. Checked at ffmpeg.org on 15 September 2026. Render the timeline as one graph and keep concat for parts you produced yourself with identical settings. ===== OPERATIONS.md ===== # Operations · Kapwing ## Backup The database and the project exports, not the media and not the proxies. The edit list is small, irreplaceable and the only thing representing your work; the source files you already have and the proxies regenerate. ## Restore Restore the database, reopen a project and confirm every source hash still matches before rendering. A mismatched hash means the file changed underneath the edit list and the cut points may no longer mean what they meant. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Render failures by state, duration mismatches, free disk on the temporary volume, and the age of the oldest temporary file. The last two together are what stops a long render taking the machine down. ## Incident checklist The incident is a finished render that is wrong, usually by a frame or by an audio offset. The stored command, the FFmpeg path, the preset arguments and the probe record together let you rerun exactly what ran rather than approximately what you remember. 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 - [ ] Source file hashes are confirmed unchanged after a full render - [ ] Split and rejoin is confirmed to reproduce the original duration exactly - [ ] Preview and export graphs are confirmed to differ only in scale and output - [ ] A delivery export is confirmed to carry its index at the front - [ ] Cancellation is confirmed to leave no partial output and no orphan process ## Launch constraint Do not market omitted Kapwing 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. Media records, projects, edit lists and render history. DATABASE_PATH=./data/editor.db # Required. Where imported files are referenced from. They are never written to. MEDIA_DIR=./data/media # Required. Low resolution previews. Safe to delete at any time. PROXY_DIR=./data/proxies # Required. Render intermediates. Swept by age, and never on the same volume as your only copy of anything. TEMP_DIR=./data/tmp # Required. Finished renders. A failed render leaves nothing here. OUTPUT_DIR=./data/exports # Required. A render below this is refused with the actual figure. MIN_FREE_DISK_GB=20 # Required. Preview resolution. Lower makes scrubbing quicker and changes nothing about exports. PROXY_HEIGHT=360 # Required. Recorded on every render row, because a version change explains a different output. FFMPEG_PATH=/usr/bin/ffmpeg
# Kapwing · indie build A local editor whose hard problems are describing an edit in rational time so cuts land on frame boundaries, keeping the preview and the export on one filter graph so faults appear early, and producing a container that starts playing before it has finished downloading. Estimated effort: **multi-day**. 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 | Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess | FFmpeg is the renderer, and wrapping it carelessly is the main way a project like this goes wrong | | Edit model | A non-destructive edit list in SQLite, with source files opened read-only | an editor that has already written over your only copy of something is not an editor | | Times | Integers in the probed time base, never floating point seconds | frame boundaries do not land on decimal seconds and the error accumulates along the timeline | | Preview | Low resolution proxies generated on import, rendered through the same filter graph | a preview built by a second code path is a preview that lies to you | | Export | One graph, one encode, then a delivery pass for the container | chained encodes lose quality twice and hide which stage introduced the fault | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the interface, the queue and the FFmpeg wrapper. - Get it: python.org or your version manager. - Verify: python3 --version prints 3.12 or higher - [ ] **FFmpeg and its probe tool, recent build** · free - Why: It is the entire renderer, and an old build will be missing options this project assumes. - Get it: Your package manager or a static build from the project. - Verify: Both binaries run and report a version, and the mp4 muxer lists the faststart option - [ ] **A fixture set with a portrait phone clip and a screen recording** · free - Why: Rotation metadata and an irregular frame rate are the two inputs that break a naive editor, and neither appears in a clean test clip. - Get it: Record ten seconds on a phone held upright and ten seconds of your screen. - Verify: Both import, probe correctly and export without rotation or drift - [ ] **Free disk several times the size of your longest source** · free - Why: An intermediate render plus proxies plus the output can be several times the input, and a disk that fills mid-render takes the project directory with it. - Get it: Point the temporary directory at a volume with room and set the ceiling below its free space. - Verify: A render is refused when free space is below the configured figure ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Real-time collaborative editing. - Automatic subtitles. - Professional colour management and broadcast codecs. - Hosting and distribution. - distribution and podcast hosting - professional codecs, effects, and collaboration - high-fidelity color, format, and export handling - hosted recording reliability - large-scale rendering If one of those is essential to you, that is the reason to keep paying for Kapwing, and the README should say so rather than pretend.
# Build brief · Kapwing 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 local video editing workspace that never writes to your source files and renders one reproducible export to replace Kapwing. 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: Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess · FFmpeg is the renderer, and wrapping it carelessly is the main way a project like this goes wrong - Edit model: A non-destructive edit list in SQLite, with source files opened read-only · an editor that has already written over your only copy of something is not an editor - Times: Integers in the probed time base, never floating point seconds · frame boundaries do not land on decimal seconds and the error accumulates along the timeline - Preview: Low resolution proxies generated on import, rendered through the same filter graph · a preview built by a second code path is a preview that lies to you - Export: One graph, one encode, then a delivery pass for the container · chained encodes lose quality twice and hide which stage introduced the fault ### Data model (create this before Phase 1) - media(id, path, sha256, probe_json, duration_ms, video_codec, audio_codec, time_base, avg_fps, is_variable_fps, rotation, sample_rate, channels, imported_at) - proxies(media_id, path, width, height, generated_at) · regenerable, and never an input to an export - projects(id, name, width, height, fps_num, fps_den, sample_rate, created_at) · the frame rate is a fraction because many real ones are not integers - clips(id, project_id, track, media_id, source_in, source_out, timeline_in, ordinal, transform_json) · times as integers in the project time base - renders(id, project_id, preset_id, started_at, finished_at, state, command_text, stderr_tail, output_path, expected_duration_ms, actual_duration_ms) - presets(id, name, container, video_args_json, audio_args_json, delivery_pass) · the arguments live in data so an old render can be reproduced exactly - Nothing in this schema writes to a source file. The edit list plus the probe record is enough to reproduce any export, which is what makes a bad render debuggable a week later. ### Phase 1 · Probe everything, trust nothing Build: Run a probe on every import and store the output verbatim alongside the extracted fields: codecs, time base, average and reported frame rate, whether the frame rate is variable, rotation, sample rate and channel layout. Hash the file. Refuse an import that does not probe, showing the error. Trap: A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. Done when: A portrait clip recorded on a phone exports upright, tested against a fixture. The probe output is stored verbatim and the extracted fields match it, tested. A file that fails to probe is refused with the error shown rather than imported as a broken row. Re-importing the same file is detected by hash and does not create a second media row. Do not build yet: No timeline, preview, export or queue yet. ### Phase 2 · An edit list, and sources opened read-only Build: Represent every clip as a source range and a timeline position in integers in the project time base. Convert on entry from whatever the interface produced. Open source files read-only and verify the stored hash before a render. Serialise the whole project to JSON and back. Trap: Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. Done when: Splitting a clip and rejoining the halves reproduces the original duration exactly, tested. No timeline or source value is stored as a floating point number, verified by schema review and a test. Source file modification times and hashes are unchanged after a full render, asserted in the suite. A project serialises to JSON and reloads to an identical edit list, tested. Do not build yet: No preview, proxies, export or queue yet. ### Phase 3 · A preview that is the export, only smaller Build: Generate low resolution proxies on import. Build the filter graph from a single function that takes a scale and an output target. Render the preview from the proxies through that graph. Never let a proxy become an input to an export. Trap: Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. Done when: A test asserts the preview and export graphs differ only in scale and output arguments. Deleting every proxy degrades the preview and changes no export output, tested. Proxies are regenerated from source and never read during an export, asserted by inspecting the render command. Scrubbing a timeline of a stated length stays responsive on the development machine, measured and recorded. Do not build yet: No delivery container work, render queue or disk limits yet. ### Phase 4 · A file that starts playing before it has finished downloading Build: Render the timeline as a single pass through one graph. For delivery, add the option that moves the index to the front of the container. Compare the rendered duration against the timeline and fail the render if they differ by more than a frame. Store the exact command and the error tail. Trap: Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. Done when: A delivery export has its index at the front, confirmed by inspecting the file rather than by trusting the flag. A render whose output duration differs from the timeline by more than one frame is marked failed and says by how much, tested by forcing a mismatch. The exact command and the last lines of the error output are stored on the render row and shown in the interface. A failed render leaves no file in the output directory. Do not build yet: No queue, cancellation, disk ceiling or project export yet. ### Phase 5 · Disk, cancellation, and jobs that outlive the tab Build: Run one render at a time from a queue that survives a page reload. Cancellation kills the process and removes the partial output. Check free disk before starting and while running, and stop with a figure rather than an error. Sweep temporary files by age. Export the project as JSON plus a manifest of source hashes so it can be reopened elsewhere against the same files. Trap: Assembling an export by rendering each clip separately and joining them with the concat demuxer looks efficient and drifts the audio of everything after the first slightly wrong duration, because the documentation requires that 'all files must have the same streams (same codecs, same time base, etc.)' and adjusts each file's timestamps using its stored duration, which it also says 'can cause artifacts' when that duration is incorrect. Checked at ffmpeg.org on 15 September 2026. Render the timeline as one graph and keep concat for parts you produced yourself with identical settings. Done when: Cancelling a render kills the process, removes the partial output and leaves no orphan, tested by cancelling mid-render. Starting a render with less than the configured free disk is refused with the actual figure in the message. The temporary sweep removes files past a stated age and never touches a running render's files, tested with a live job. A project exported with its source manifest opens on another machine with the same files and re-renders to the same duration. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaborative editing, which needs a shared document model and conflict handling that is a larger project than the editor itself. - Automatic subtitles, which are a separate accuracy claim and are not worth shipping without a correction interface in front of them. - Professional colour management and broadcast codecs, which need calibrated hardware and licences before any of it is verifiable. - Hosting and distribution of finished videos, which is storage, bandwidth and takedown handling rather than an editor feature. ### README must contain - That source files are opened read-only and that the suite asserts their hashes are unchanged after a render - That timeline values are integers in the project time base, with the split-and-rejoin test named as the reason - That the preview and the export come from one filter graph, and what the parity test checks - That the delivery preset moves the container index to the front, quoting the FFmpeg documentation with the date checked, and what that costs in render time - Where temporary files live, how much free disk a render needs, and what cancellation cleans up
# Agent instructions · Kapwing indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess, A non-destructive edit list in SQLite, with source files opened read-only, Integers in the probed time base, never floating point seconds, Low resolution proxies generated on import, rendered through the same filter graph, One graph, one encode, then a delivery pass for the container. 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 - A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. - Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. - Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. - Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe.
# Build plan · Kapwing A local editor whose hard problems are describing an edit in rational time so cuts land on frame boundaries, keeping the preview and the export on one filter graph so faults appear early, and producing a container that starts playing before it has finished downloading. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Probe everything, trust nothing Import records what a file actually is, and refuses anything it cannot describe. ### Steps 1. Store the probe output verbatim as well as the parsed fields The parse will be wrong about something and you will want the original. Files: `src/media/probe.py`, `tests/test_probe.py` 2. Normalise rotation in the graph, not in the player The player was lying to you comfortably. Files: `src/render/graph.py`, `tests/fixtures/portrait.mp4` 3. Refuse what you cannot describe A media row with unknown fields poisons every render that touches it. Files: `src/media/import.py` ### Done when - [ ] A portrait phone clip exports upright - [ ] Probe output is stored verbatim and matches the parsed fields - [ ] An unprobeable file is refused with its error - [ ] Re-import is detected by hash ### Watch out - A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. ## Phase 2 · An edit list, and sources opened read-only Trimming, splitting and reordering change rows only, and the source files are provably untouched. ### Steps 1. Convert to the project time base at the edge Nothing past the edge should ever see a decimal second. Files: `src/timeline/units.py`, `tests/test_units.py` 2. Open sources read-only and verify the hash Both, because read-only is a habit and the hash is the proof. Files: `src/media/open.py`, `tests/test_untouched.py` 3. Make the project serialise and reload identically It is the cheapest test of whether the model is complete. Files: `src/project/serialise.py` ### Done when - [ ] Split and rejoin reproduces the exact duration - [ ] No time value is stored as a float - [ ] Source files are unchanged after a render - [ ] Project JSON round-trips identically ### Watch out - Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. ## Phase 3 · A preview that is the export, only smaller One function builds the filter graph and both the preview and the export use it. ### Steps 1. Build both graphs from one function Two graphs is two products with one name. Files: `src/render/graph.py`, `tests/test_graph_parity.py` 2. Render the preview from proxies, the export from sources And assert the export command never mentions a proxy path. Files: `src/render/preview.py`, `src/render/export.py` 3. Regenerate proxies rather than protecting them They are cache, and treating them as cache keeps them honest. Files: `src/media/proxy.py` ### Done when - [ ] Preview and export graphs differ only in scale and output - [ ] Deleting proxies changes no export - [ ] No export command references a proxy - [ ] Scrubbing is responsive at a stated timeline length ### Watch out - Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. ## Phase 4 · A file that starts playing before it has finished downloading The delivery export carries its index at the front and its duration is checked, not assumed. ### Steps 1. Add the delivery pass to the preset, in data not in code So an old render's arguments can be read back exactly. Files: `src/presets.py`, `data/presets.json` 2. Compare rendered duration against the timeline A silent duration drift is the fault you find on upload day. Files: `src/render/verify.py`, `tests/test_duration.py` 3. Store the command and the error tail on the render row A render you cannot reproduce is a render you cannot fix. Files: `src/render/run.py` ### Done when - [ ] The delivery file carries its index at the front - [ ] A duration mismatch fails the render and says by how much - [ ] Command text and error tail are stored per render - [ ] A failed render leaves no output file ### Watch out - Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. ## Not in this build - Real-time collaborative editing. - Automatic subtitles. - Professional colour management and broadcast codecs. - Hosting and distribution. ## After v1, if you want it - A waveform view generated from the proxy audio, which makes cutting on speech possible - Render presets per destination stored as data, so a new target is a row rather than a branch - A watch folder that probes and imports automatically, refusing anything it cannot describe
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Media records, projects, edit lists and render history. DATABASE_PATH=./data/editor.db # Required. Where imported files are referenced from. They are never written to. MEDIA_DIR=./data/media # Required. Low resolution previews. Safe to delete at any time. PROXY_DIR=./data/proxies # Required. Render intermediates. Swept by age, and never on the same volume as your only copy of anything. TEMP_DIR=./data/tmp # Required. Finished renders. A failed render leaves nothing here. OUTPUT_DIR=./data/exports # Required. A render below this is refused with the actual figure. MIN_FREE_DISK_GB=20 # Required. Preview resolution. Lower makes scrubbing quicker and changes nothing about exports. PROXY_HEIGHT=360 # Required. Recorded on every render row, because a version change explains a different output. FFMPEG_PATH=/usr/bin/ffmpeg
# Kapwing · product brief ## Problem The visible collaborative video editor loop is buildable, but a credible replacement needs more than the first screen. Kapwing earns its keep through media pipeline, hosting, distribution, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome An editor that leaves your source files exactly as it found them, cuts on frame boundaries because the timeline is integers, shows you faults in the preview rather than at the end of a long render, and produces a file that starts playing over the web. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Python 3.12 - FFmpeg - desktop with sufficient storage - optional model or API key in .env ## Explicit non-goals for v1 - Real-time collaborative editing. - Automatic subtitles. - Professional colour management and broadcast codecs. - Hosting and distribution. - distribution and podcast hosting - professional codecs, effects, and collaboration - high-fidelity color, format, and export handling - hosted recording reliability - large-scale rendering ## Success criteria - Source file hashes are confirmed unchanged after a full render - Split and rejoin is confirmed to reproduce the original duration exactly - Preview and export graphs are confirmed to differ only in scale and output - A delivery export is confirmed to carry its index at the front - Cancellation is confirmed to leave no partial output and no orphan process
# Build brief · Kapwing 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 local video editing workspace that never writes to your source files and renders one reproducible export to replace Kapwing. 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: Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess · FFmpeg is the renderer, and wrapping it carelessly is the main way a project like this goes wrong - Edit model: A non-destructive edit list in SQLite, with source files opened read-only · an editor that has already written over your only copy of something is not an editor - Times: Integers in the probed time base, never floating point seconds · frame boundaries do not land on decimal seconds and the error accumulates along the timeline - Preview: Low resolution proxies generated on import, rendered through the same filter graph · a preview built by a second code path is a preview that lies to you - Export: One graph, one encode, then a delivery pass for the container · chained encodes lose quality twice and hide which stage introduced the fault ### Data model (create this before Phase 1) - media(id, path, sha256, probe_json, duration_ms, video_codec, audio_codec, time_base, avg_fps, is_variable_fps, rotation, sample_rate, channels, imported_at) - proxies(media_id, path, width, height, generated_at) · regenerable, and never an input to an export - projects(id, name, width, height, fps_num, fps_den, sample_rate, created_at) · the frame rate is a fraction because many real ones are not integers - clips(id, project_id, track, media_id, source_in, source_out, timeline_in, ordinal, transform_json) · times as integers in the project time base - renders(id, project_id, preset_id, started_at, finished_at, state, command_text, stderr_tail, output_path, expected_duration_ms, actual_duration_ms) - presets(id, name, container, video_args_json, audio_args_json, delivery_pass) · the arguments live in data so an old render can be reproduced exactly - Nothing in this schema writes to a source file. The edit list plus the probe record is enough to reproduce any export, which is what makes a bad render debuggable a week later. ### Phase 1 · Probe everything, trust nothing Build: Run a probe on every import and store the output verbatim alongside the extracted fields: codecs, time base, average and reported frame rate, whether the frame rate is variable, rotation, sample rate and channel layout. Hash the file. Refuse an import that does not probe, showing the error. Trap: A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. Done when: A portrait clip recorded on a phone exports upright, tested against a fixture. The probe output is stored verbatim and the extracted fields match it, tested. A file that fails to probe is refused with the error shown rather than imported as a broken row. Re-importing the same file is detected by hash and does not create a second media row. Do not build yet: No timeline, preview, export or queue yet. ### Phase 2 · An edit list, and sources opened read-only Build: Represent every clip as a source range and a timeline position in integers in the project time base. Convert on entry from whatever the interface produced. Open source files read-only and verify the stored hash before a render. Serialise the whole project to JSON and back. Trap: Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. Done when: Splitting a clip and rejoining the halves reproduces the original duration exactly, tested. No timeline or source value is stored as a floating point number, verified by schema review and a test. Source file modification times and hashes are unchanged after a full render, asserted in the suite. A project serialises to JSON and reloads to an identical edit list, tested. Do not build yet: No preview, proxies, export or queue yet. ### Phase 3 · A preview that is the export, only smaller Build: Generate low resolution proxies on import. Build the filter graph from a single function that takes a scale and an output target. Render the preview from the proxies through that graph. Never let a proxy become an input to an export. Trap: Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. Done when: A test asserts the preview and export graphs differ only in scale and output arguments. Deleting every proxy degrades the preview and changes no export output, tested. Proxies are regenerated from source and never read during an export, asserted by inspecting the render command. Scrubbing a timeline of a stated length stays responsive on the development machine, measured and recorded. Do not build yet: No delivery container work, render queue or disk limits yet. ### Phase 4 · A file that starts playing before it has finished downloading Build: Render the timeline as a single pass through one graph. For delivery, add the option that moves the index to the front of the container. Compare the rendered duration against the timeline and fail the render if they differ by more than a frame. Store the exact command and the error tail. Trap: Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. Done when: A delivery export has its index at the front, confirmed by inspecting the file rather than by trusting the flag. A render whose output duration differs from the timeline by more than one frame is marked failed and says by how much, tested by forcing a mismatch. The exact command and the last lines of the error output are stored on the render row and shown in the interface. A failed render leaves no file in the output directory. Do not build yet: No queue, cancellation, disk ceiling or project export yet. ### Phase 5 · Disk, cancellation, and jobs that outlive the tab Build: Run one render at a time from a queue that survives a page reload. Cancellation kills the process and removes the partial output. Check free disk before starting and while running, and stop with a figure rather than an error. Sweep temporary files by age. Export the project as JSON plus a manifest of source hashes so it can be reopened elsewhere against the same files. Trap: Assembling an export by rendering each clip separately and joining them with the concat demuxer looks efficient and drifts the audio of everything after the first slightly wrong duration, because the documentation requires that 'all files must have the same streams (same codecs, same time base, etc.)' and adjusts each file's timestamps using its stored duration, which it also says 'can cause artifacts' when that duration is incorrect. Checked at ffmpeg.org on 15 September 2026. Render the timeline as one graph and keep concat for parts you produced yourself with identical settings. Done when: Cancelling a render kills the process, removes the partial output and leaves no orphan, tested by cancelling mid-render. Starting a render with less than the configured free disk is refused with the actual figure in the message. The temporary sweep removes files past a stated age and never touches a running render's files, tested with a live job. A project exported with its source manifest opens on another machine with the same files and re-renders to the same duration. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaborative editing, which needs a shared document model and conflict handling that is a larger project than the editor itself. - Automatic subtitles, which are a separate accuracy claim and are not worth shipping without a correction interface in front of them. - Professional colour management and broadcast codecs, which need calibrated hardware and licences before any of it is verifiable. - Hosting and distribution of finished videos, which is storage, bandwidth and takedown handling rather than an editor feature. ### README must contain - That source files are opened read-only and that the suite asserts their hashes are unchanged after a render - That timeline values are integers in the project time base, with the split-and-rejoin test named as the reason - That the preview and the export come from one filter graph, and what the parity test checks - That the delivery preset moves the container index to the front, quoting the FFmpeg documentation with the date checked, and what that costs in render time - Where temporary files live, how much free disk a render needs, and what cancellation cleans up
# Architecture · Kapwing ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess | FFmpeg is the renderer, and wrapping it carelessly is the main way a project like this goes wrong | | Edit model | A non-destructive edit list in SQLite, with source files opened read-only | an editor that has already written over your only copy of something is not an editor | | Times | Integers in the probed time base, never floating point seconds | frame boundaries do not land on decimal seconds and the error accumulates along the timeline | | Preview | Low resolution proxies generated on import, rendered through the same filter graph | a preview built by a second code path is a preview that lies to you | | Export | One graph, one encode, then a delivery pass for the container | chained encodes lose quality twice and hide which stage introduced the fault | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Media | Probe, hashing, rotation and refusal. | Any prober, provided its raw output is stored alongside the parsed fields. | | Timeline | Clips, ranges and the time base. | Any representation that is integers in a stated time base. Never seconds as floats. | | Graph | The one filter graph builder. | Not swappable for two builders, which is the failure this module exists to prevent. | | Render | Execution, duration verification and history. | Any runner, provided the command and error tail are stored. | | Queue | One job at a time, cancellation and disk limits. | Any queue that outlives the browser tab. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Media records, projects, edit lists and render history. - `MEDIA_DIR` · required · Where imported files are referenced from. They are never written to. - `PROXY_DIR` · required · Low resolution previews. Safe to delete at any time. - `TEMP_DIR` · required · Render intermediates. Swept by age, and never on the same volume as your only copy of anything. - `OUTPUT_DIR` · required · Finished renders. A failed render leaves nothing here. - `MIN_FREE_DISK_GB` · required · A render below this is refused with the actual figure. - `PROXY_HEIGHT` · required · Preview resolution. Lower makes scrubbing quicker and changes nothing about exports. - `FFMPEG_PATH` · required · Recorded on every render row, because a version change explains a different output. ## 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 · Kapwing product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Python 3.12 with a small local web interface, FFmpeg invoked as a subprocess, A non-destructive edit list in SQLite, with source files opened read-only, Integers in the probed time base, never floating point seconds, Low resolution proxies generated on import, rendered through the same filter graph, One graph, one encode, then a delivery pass for the container. - 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 - A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. - Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. - Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. - Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. - Assembling an export by rendering each clip separately and joining them with the concat demuxer looks efficient and drifts the audio of everything after the first slightly wrong duration, because the documentation requires that 'all files must have the same streams (same codecs, same time base, etc.)' and adjusts each file's timestamps using its stored duration, which it also says 'can cause artifacts' when that duration is incorrect. Checked at ffmpeg.org on 15 September 2026. Render the timeline as one graph and keep concat for parts you produced yourself with identical settings.
# Delivery milestones · Kapwing Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Probe everything, trust nothing Import records what a file actually is, and refuses anything it cannot describe. ### Steps 1. Store the probe output verbatim as well as the parsed fields The parse will be wrong about something and you will want the original. Files: `src/media/probe.py`, `tests/test_probe.py` 2. Normalise rotation in the graph, not in the player The player was lying to you comfortably. Files: `src/render/graph.py`, `tests/fixtures/portrait.mp4` 3. Refuse what you cannot describe A media row with unknown fields poisons every render that touches it. Files: `src/media/import.py` ### Done when - [ ] A portrait phone clip exports upright - [ ] Probe output is stored verbatim and matches the parsed fields - [ ] An unprobeable file is refused with its error - [ ] Re-import is detected by hash ### Watch out - A clip from a phone carries its orientation as rotation metadata and plays upright in every player you check it in, so you build the entire timeline before discovering the export is on its side, because the encoder applied your filters to the stored orientation rather than the displayed one. Read the rotation at import, normalise it in the filter graph, and put a portrait phone clip in the fixture set on the first day rather than the last. ## M2 · An edit list, and sources opened read-only Trimming, splitting and reordering change rows only, and the source files are provably untouched. ### Steps 1. Convert to the project time base at the edge Nothing past the edge should ever see a decimal second. Files: `src/timeline/units.py`, `tests/test_units.py` 2. Open sources read-only and verify the hash Both, because read-only is a habit and the hash is the proof. Files: `src/media/open.py`, `tests/test_untouched.py` 3. Make the project serialise and reload identically It is the cheapest test of whether the model is complete. Files: `src/project/serialise.py` ### Done when - [ ] Split and rejoin reproduces the exact duration - [ ] No time value is stored as a float - [ ] Source files are unchanged after a render - [ ] Project JSON round-trips identically ### Watch out - Storing cut points as floating point seconds survives every test you write and then splits a clip at twelve point three seconds one frame early on one render and one frame late on the next, because the container's time base is not a decimal fraction, and the two halves stop adding up to the original. Convert to the project time base on entry, keep integers everywhere, and assert that splitting a clip and rejoining the halves reproduces the original duration exactly. ## M3 · A preview that is the export, only smaller One function builds the filter graph and both the preview and the export use it. ### Steps 1. Build both graphs from one function Two graphs is two products with one name. Files: `src/render/graph.py`, `tests/test_graph_parity.py` 2. Render the preview from proxies, the export from sources And assert the export command never mentions a proxy path. Files: `src/render/preview.py`, `src/render/export.py` 3. Regenerate proxies rather than protecting them They are cache, and treating them as cache keeps them honest. Files: `src/media/proxy.py` ### Done when - [ ] Preview and export graphs differ only in scale and output - [ ] Deleting proxies changes no export - [ ] No export command references a proxy - [ ] Scrubbing is responsive at a stated timeline length ### Watch out - Writing the preview with one set of filters and the export with another means every fault appears only at the end of a forty minute render, and the one you chase longest is the one the preview could never have shown you. Generate both graphs from one function and put a test that asserts the two differ only in the scale and the output arguments. ## M4 · A file that starts playing before it has finished downloading The delivery export carries its index at the front and its duration is checked, not assumed. ### Steps 1. Add the delivery pass to the preset, in data not in code So an old render's arguments can be read back exactly. Files: `src/presets.py`, `data/presets.json` 2. Compare rendered duration against the timeline A silent duration drift is the fault you find on upload day. Files: `src/render/verify.py`, `tests/test_duration.py` 3. Store the command and the error tail on the render row A render you cannot reproduce is a render you cannot fix. Files: `src/render/run.py` ### Done when - [ ] The delivery file carries its index at the front - [ ] A duration mismatch fails the render and says by how much - [ ] Command text and error tail are stored per render - [ ] A failed render leaves no output file ### Watch out - Leave the faststart option off and the container's index sits at the end, which the FFmpeg documentation describes explicitly: the option will 'run a second pass moving the index (moov atom) to the beginning of the file', and without it a player fetching the file over http has to reach the tail before it can begin, so your export plays perfectly from disk and stalls on the web. Checked at ffmpeg.org on 15 September 2026; note the same page says it 'will not work in various situations such as fragmented output' and that it needs a seekable output rather than a pipe. ## M5 · Disk, cancellation, and jobs that outlive the tab (production only) A long render can be cancelled cleanly, cannot fill the disk, and the project can move machines. ### Steps 1. Queue one render at a time and survive a reload The browser tab is not where the job should live. Files: `src/queue.py`, `tests/test_queue.py` 2. Check disk before and during, and stop with the number A full disk mid-render corrupts the output and the project directory. Files: `src/render/disk.py` 3. Export the project with a manifest of source hashes So reopening it elsewhere fails loudly on a missing file. Files: `src/project/export.py`, `tests/test_portable.py` ### Done when - [ ] Cancellation leaves no partial file or orphan process - [ ] A low disk start is refused with the figure - [ ] The temp sweep spares a running job's files - [ ] A project plus manifest reopens and re-renders identically ### Watch out - Assembling an export by rendering each clip separately and joining them with the concat demuxer looks efficient and drifts the audio of everything after the first slightly wrong duration, because the documentation requires that 'all files must have the same streams (same codecs, same time base, etc.)' and adjusts each file's timestamps using its stored duration, which it also says 'can cause artifacts' when that duration is incorrect. Checked at ffmpeg.org on 15 September 2026. Render the timeline as one graph and keep concat for parts you produced yourself with identical settings.
# Operations · Kapwing ## Backup The database and the project exports, not the media and not the proxies. The edit list is small, irreplaceable and the only thing representing your work; the source files you already have and the proxies regenerate. ## Restore Restore the database, reopen a project and confirm every source hash still matches before rendering. A mismatched hash means the file changed underneath the edit list and the cut points may no longer mean what they meant. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Render failures by state, duration mismatches, free disk on the temporary volume, and the age of the oldest temporary file. The last two together are what stops a long render taking the machine down. ## Incident checklist The incident is a finished render that is wrong, usually by a frame or by an audio offset. The stored command, the FFmpeg path, the preset arguments and the probe record together let you rerun exactly what ran rather than approximately what you remember. 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 - [ ] Source file hashes are confirmed unchanged after a full render - [ ] Split and rejoin is confirmed to reproduce the original duration exactly - [ ] Preview and export graphs are confirmed to differ only in scale and output - [ ] A delivery export is confirmed to carry its index at the front - [ ] Cancellation is confirmed to leave no partial output and no orphan process ## Launch constraint Do not market omitted Kapwing 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. Media records, projects, edit lists and render history. DATABASE_PATH=./data/editor.db # Required. Where imported files are referenced from. They are never written to. MEDIA_DIR=./data/media # Required. Low resolution previews. Safe to delete at any time. PROXY_DIR=./data/proxies # Required. Render intermediates. Swept by age, and never on the same volume as your only copy of anything. TEMP_DIR=./data/tmp # Required. Finished renders. A failed render leaves nothing here. OUTPUT_DIR=./data/exports # Required. A render below this is refused with the actual figure. MIN_FREE_DISK_GB=20 # Required. Preview resolution. Lower makes scrubbing quicker and changes nothing about exports. PROXY_HEIGHT=360 # Required. Recorded on every render row, because a version change explains a different output. FFMPEG_PATH=/usr/bin/ffmpeg
$ choose a build depth, inspect the files, then open the complete pack in your agent
Kapwing: People pay to avoid codec failures, upload limits, rendering surprises, lost recordings, and distribution chores.
xdistribution and podcast hosting
xprofessional codecs, effects, and collaboration
xhigh-fidelity color, format, and export handling
xhosted recording reliability
xlarge-scale rendering
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Kapwing pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | 10 credits; 30 export minutes/month; 4-minute videos; 720p watermarked; 2 GB uploads; projects retained 3 days |
| pro | $24/user | $16/user | 1,000 credits/month; 2-hour exports; 4K; 6 GB uploads; 100 GB storage; 1,000 subtitle minutes/month |
| business | $64/user | $50/user | 4,000 credits/month; 500 GB storage; 4,000 subtitle minutes; 200 dubbing minutes; 2,000 translation minutes/month |
| enterprise | custom | — | Custom credits, storage, security, support and procurement |
free tier10 credits, 30 export minutes/month, 4-minute maximum video, 720p watermarked exports, 2 GB uploads and 3-day project retention
billingmonthly + annual; subscriptions auto-renew and published policy says payments are non-refundable
hidden costsAI tools consume credits at different rates; free projects are deleted after 3 days, and every paid collaborator is a separate paid member.
verified 2026-08-14 · source ↗
Vibecode Kapwing
Kinda. The core of Kapwing is buildable in a weekend with the prompt on this page, but there are real gaps: distribution and podcast hosting, professional codecs, effects, and collaboration. Read the honest list above before committing.
How much does Kapwing cost?
Kapwing costs about $24/month (Pro, checked 2026-07-31), which is $288 per year.
What do I lose by replacing Kapwing?
Honestly: distribution and podcast hosting; professional codecs, effects, and collaboration; high-fidelity color, format, and export handling; hosted recording reliability; large-scale rendering. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Kapwing?
Yes: Adobe Express (Browser video, images, captions, graphics and memes without a watermark; team niceties remain paid.) Clipchamp (A capable browser editor with free 1080p exports; image-and-meme work is much thinner than Kapwing.) The prompt is for when you want it exactly your way.