Skip to content

Render real video, with OpenCut's engine - #22

Open
deonmenezes wants to merge 5 commits into
mainfrom
feat/real-render
Open

deonmenezes wants to merge 5 commits into
mainfrom
feat/real-render

Conversation

@deonmenezes

Copy link
Copy Markdown
Owner

The problem

The timeline was a simulation end to end:

  • No media existed. intro.mp4, voiceover.wav and the rest were strings in seedProject(). find . -name "*.mp4" returned nothing.
  • The preview was not a video player. preview.tsx mapped each filename to a CSS gradient, and use-playback.ts was a requestAnimationFrame counter. There was no <video> or <canvas> in the codebase.
  • Export did not encode. exportProject() did writeFileSync(rec.file, JSON.stringify({export, project})), writing a JSON blob to a file named .mp4.
  • Transcripts, silences and BPM were literals in the seed object.
  • The ffmpeg sandbox was orphaned. Real ffmpeg, but pointed at its own workspace/. Nothing connected a timeline clip to bytes on disk.

The approach

Port the renderer from OpenCut, which EditAI's LICENSE already credits but whose engine had never been brought over: mediabunny over WebCodecs for decode/mux/encode, and OpenCut's frame-cache design.

The one deliberate departure is compositing in Canvas2D rather than OpenCut's wgpu compositor. EditAI stacks video, text and audio with no effects, masks or blend modes, which 2D covers exactly, and it drops the opencut-wasm dependency along with their tick-based scene graph. Effects would need the real thing, and the README says so.

What changed

Import (engine/media.ts, use-media-import.ts) measures a file with WebCodecs, uploads the bytes to the agent, then analyzes the audio: silences from windowed RMS, a peak envelope for the timeline waveform, tempo from onset autocorrelation. find_silences and detect_beats now answer from the file. The waveform under an audio clip is its own instead of a seeded PRNG.

Preview (engine/compositor.ts, use-preview.ts) is a canvas driven by the same engine the exporter uses, so what you watch is what gets encoded. Frames come from engine/video-cache.ts, ported from OpenCut: a forward iterator with the next frame decoded ahead, falling back to a real seek only when the target is behind the decoder or too far in front. A naive getCanvas(t) per frame re-seeks every time and plays at a few frames a second.

Export (engine/exporter.ts, use-render-worker.ts) is a job. The agent queues it, the editor claims it, composites every frame, muxes with mediabunny, and posts the file back. get_export then reports a real path and a real byte count, which is what lets the agent check its own work.

Two new tools: list_media (the agent could not see what had been imported) and add_clip (it could add text to the timeline but never footage). export_project now refuses when a clip's media is missing, rather than writing a file that means nothing.

Concurrency, found the hard way

The first end-to-end run had two editor tabs open. Both rendered the same job, both uploaded to the same temp path, one renamed it, the other got ENOENT and marked a finished render failed. Fixed at the source rather than papered over:

  • Claiming is server-side (POST /exports/:id/claim, 409 if taken), so a page-local guard cannot be defeated by a second tab or a StrictMode remount.
  • Upload temp files carry a UUID, so concurrent uploads of one name cannot write through each other.
  • setExportProgress and failExport refuse to reopen a render that is already done.

All three are covered by tests.

Verification

Not just "it typechecks". Against footage generated by the new scripts/make-samples.ts:

Check Result
Render of a 24s timeline (3 video clips, 2 audio tracks, a title) 1280x720 H.264 + 48 kHz stereo AAC, 24.06s, 3.3 MB
Frame at 2s Real decoded source content, at the correct source time, with the caption composited over it
Frame at 12s The third clip, so the 11s cut is respected
Rendered audio mean -16.0 dB, max -2.5 dB
Silence detection vs. the three injected gaps (3.2-4.1, 9.6-10.4, 16.8-18.0) All three, to a tenth of a second
Tempo on a 120 BPM click track 120
Two editors open, one job One render, status done, no stray temp files

Tests

58 passing, up from 31. New coverage: media registration and clip-placement bounds, the full render lifecycle including the claim and the late-report guards, silence detection against injected gaps and sub-threshold room tone, the peak envelope, tempo recovery, and which clips reach the audio mix.

Also

apps/agent/data/project.json was both gitignored and committed, so a clone started from whatever timeline was last saved rather than from the seed. Untracked.

Known limits, stated in the README

  • transcribe_clip still reads stored segments; no ASR yet.
  • Rendering needs the editor open. A server-side ffmpeg worker on the same queue would fix it without changing a tool signature.
  • Compositing is Canvas2D, so effects and transitions have nowhere to live.
  • Codecs are the browser's; mp4 audio falls back to Opus where AAC encoding is unavailable.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D8rspEEzFARv7H1opU9ER2

deonmenezes and others added 3 commits August 29, 2026 17:34
The README described the timeline tools but not the thing people ask
about first: what you can say to it, and what happens at each of the
three layers the agent drives (your UI, ours, the ffmpeg encode). It
now carries the full 16-tool reference with arguments, the connector
story including how to attach an MCP server mid-conversation, both
sandboxes and what each is for, the environment table, test counts,
and a "Known limits" section, because export writes a render
description rather than encoding video and the demo does not make that
obvious.

The landing page in site/ renders the agent server's own sample
project and performs a real ripple delete on it: mapTime() applies the
same rule project.ts does, so clips straddling a silence get shorter
rather than merely shifting, and 24.0s becomes 21.1s. Its palette is
lifted from the editor's stylesheet and favicon rather than invented,
so the page and the product read as one thing.

Two numbers in the README were wrong and are corrected here: the
silence ranges in the walkthrough were sketched rather than read off
project.ts (they are 3.2-4.1, 9.6-10.4, 16.8-18.0), and .env.example
lives in apps/agent, not the repo root.
The navbar CTA said "GitHub" in text, which is the one link on the page
people scan for by icon rather than by reading. The mark is defined once
as an SVG symbol and used in all three places the link appears: the
navbar, the outro button, and the footer, so they read as one system
instead of three unrelated links.

The violet button picks up an inset top highlight and a soft cast of its
own colour, which is what separates a button from a coloured rectangle
at this size.

Below 480px the label is clipped rather than hidden, so the button
becomes a square mark and still announces "GitHub" to a screen reader.
The timeline was a simulation. Clips named media that did not exist, the
preview mapped those names to CSS gradients, transcripts and silences were
literals in the seed, and export_project wrote a JSON description of a render
to a file called .mp4. The ffmpeg sandbox was real but orphaned: nothing
connected a clip to bytes on disk, so it had nothing to point ffmpeg at.

This ports OpenCut's renderer, adapted to EditAI's timeline.

Import measures a real file with WebCodecs, uploads the bytes to the agent,
and analyzes the audio: silences from windowed RMS, a peak envelope for the
timeline waveform, tempo from onset autocorrelation. find_silences and
detect_beats now answer from the file rather than from a fixture, and the
waveform under an audio clip is its own.

The preview is a canvas driven by the same engine the exporter uses, so what
you watch is what gets encoded. Frames come from a cache taken from OpenCut:
a forward iterator with the next frame decoded ahead, falling back to a seek
only when the target is behind the decoder or too far in front. Audio is the
whole-timeline mixdown, played through one buffer source and re-anchored when
a scrub drifts it.

Export is a job. The agent queues it, the editor claims it, composites every
frame, muxes with mediabunny, and posts the file back; get_export then reports
a real path and a real byte count, which is what lets the agent check its own
work. Claiming is server-side, so two editors open on one project cannot both
encode it, and a finished render is not reopened by a straggling progress or
failure report from a losing worker.

Compositing is Canvas2D, not OpenCut's wgpu compositor. EditAI stacks video,
text and audio with no effects or masks, which 2D covers exactly, and it drops
a wasm dependency. Effects would need the real thing.

Two tools were missing and are added: list_media, because the agent could not
see what had been imported, and add_clip, because it could add text to the
timeline but never footage.

Verified end to end against generated footage: a 24s timeline of three video
clips, two audio tracks and a title renders to 1280x720 H.264 with 48 kHz
stereo AAC, decoding real source frames at the right source times. Silence
detection recovered all three injected gaps to a tenth of a second and tempo
came back at exactly 120 BPM on a 120 BPM click track.

data/project.json is no longer tracked. It was both gitignored and committed,
so a clone started from whatever timeline was last saved instead of the seed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8rspEEzFARv7H1opU9ER2
@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Render real media with OpenCut's WebCodecs engine

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Imports and analyzes real media for timeline-aware editing.
• Shares Canvas2D compositing across live preview and WebCodecs export.
• Queues browser render jobs and exposes their verified output to the agent.
Diagram

graph TD
  Source["Imported Media"] --> Editor["Editor UI"] --> Import["Probe and Analyze"] --> Server["Agent Server"] --> Store[("Project Media Store")]
  Store --> Engine["Shared Render Engine"] --> Preview["Canvas Preview"]
  Engine --> Output[("Encoded Export")]
  Server -->|queues job| Engine
  Output -->|uploads result| Server
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Server-side ffmpeg worker
  • ➕ Renders without an open editor
  • ➕ Supports broader codecs and centralized resource controls
  • ➖ Requires a second compositor implementation or generated filter graphs
  • ➖ Adds worker deployment, queue operations, and preview parity risk
2. Full OpenCut wgpu compositor
  • ➕ Provides a path to effects, masks, transitions, and blend modes
  • ➕ More closely matches OpenCut's complete rendering architecture
  • ➖ Adds WASM and GPU integration complexity
  • ➖ Exceeds the current stacked video, text, and audio requirements

Recommendation: Keep the shared browser renderer for the current scope: it gives preview/export parity and reuses the browser-owned decoders with less infrastructure. Add a server-side worker when headless rendering becomes necessary, and adopt the wgpu compositor only when effects require it.

Files changed (32) +3641 / -203

Enhancement (19) +1961 / -85
make-samples.tsGenerate genuine sample video and audio files +81/-0

Generate genuine sample video and audio files

• Adds an ffmpeg script that creates encoded footage, a voiceover with known silence gaps, and a 120 BPM music track.

apps/agent/scripts/make-samples.ts

media.tsSecurely stream uploaded media to disk +68/-0

Securely stream uploaded media to disk

• Adds media-name validation, MIME detection, atomic temporary-file writes, and a 2 GiB upload limit.

apps/agent/src/media.ts

project.tsModel imported media and queued render lifecycles +246/-12

Model imported media and queued render lifecycles

• Adds measured media metadata, empty projects, validated clip placement, missing-media checks, and pending-to-complete export state transitions. Completed uploads are atomically moved into place and protected from stale worker updates.

apps/agent/src/project.ts

server.tsExpose media storage and render worker APIs +155/-7

Expose media storage and render worker APIs

• Adds streamed upload, range download, media analysis, export queue, claim, progress, completion, failure, and download endpoints. Project reset can now start from an empty timeline.

apps/agent/src/server.ts

tools.tsAdd media placement and render-status MCP tools +72/-3

Add media placement and render-status MCP tools

• Introduces list_media, add_clip, and get_export. Changes export_project from writing JSON fixtures to queueing validated browser render jobs.

apps/agent/src/tools.ts

data.tsType media metadata and export records +40/-1

Type media metadata and export records

• Mirrors the agent's measured media, analysis, and render lifecycle types in the editor project model.

apps/web/src/components/editor/data.ts

preview.tsxReplace simulated scenes with a canvas program monitor +41/-21

Replace simulated scenes with a canvas program monitor

• Renders the shared engine's canvas output, adds real audio muting, and reports decoding, missing media, and render errors.

apps/web/src/components/editor/preview.tsx

side-panel.tsxAdd real media importing to the side panel +79/-20

Add real media importing to the side panel

• Adds multi-file selection, import progress and errors, measured metadata, timeline usage, and missing-file indicators.

apps/web/src/components/editor/side-panel.tsx

timeline.tsxDraw waveforms from measured audio peaks +29/-9

Draw waveforms from measured audio peaks

• Maps each clip's source offset and duration into its imported media envelope, retaining a stable placeholder before analysis.

apps/web/src/components/editor/timeline.tsx

top-bar.tsxSurface render progress and completed downloads +21/-8

Surface render progress and completed downloads

• Connects the export button to render state and exposes the latest completed file with its real byte size.

apps/web/src/components/editor/top-bar.tsx

use-media-import.tsOrchestrate media probing, upload, and analysis +63/-0

Orchestrate media probing, upload, and analysis

• Probes browser codec support, uploads bytes, invalidates decoder caches, and posts measured silences, peaks, and tempo.

apps/web/src/components/editor/use-media-import.ts

use-preview.tsDrive synchronized canvas and audio preview +157/-0

Drive synchronized canvas and audio preview

• Serializes frame decoding, paints the latest requested timeline time, mixes real audio, and resynchronizes playback after seeks or edits.

apps/web/src/components/editor/use-preview.ts

use-render-worker.tsClaim and execute queued browser renders +87/-0

Claim and execute queued browser renders

• Claims each pending job once, reports throttled progress, uploads the encoded blob, and records render failures.

apps/web/src/components/editor/use-render-worker.ts

audio.tsDecode, analyze, and mix timeline audio +264/-0

Decode, analyze, and mix timeline audio

• Adds cached browser and mediabunny decoding, stereo timeline mixdown, limiting, RMS silence detection, peak extraction, and onset-autocorrelation tempo estimation.

apps/web/src/engine/audio.ts

compositor.tsComposite timeline frames with Canvas2D +98/-0

Composite timeline frames with Canvas2D

• Draws active video by track order with cover cropping and overlays wrapped text captions on shared preview/export surfaces.

apps/web/src/engine/compositor.ts

exporter.tsEncode real MP4 and WebM timeline exports +135/-0

Encode real MP4 and WebM timeline exports

• Uses mediabunny CanvasSource and AudioBufferSource to encode composited frames and mixed audio with progress and cancellation support.

apps/web/src/engine/exporter.ts

media.tsProbe and open range-backed media inputs +71/-0

Probe and open range-backed media inputs

• Measures local files before upload and caches mediabunny URL inputs so large source files can seek through HTTP ranges.

apps/web/src/engine/media.ts

video-cache.tsPort OpenCut's prefetched frame cache +195/-0

Port OpenCut's prefetched frame cache

• Maintains serialized forward decoders with next-frame prefetch and seeks only for backward or distant requests. Separate cache instances isolate preview scrubbing from export.

apps/web/src/engine/video-cache.ts

index.tsxWire imports, rendering, and drag-drop into the editor +59/-4

Wire imports, rendering, and drag-drop into the editor

• Connects media import, render workers, export eligibility, completed downloads, and full-editor file dropping to project state.

apps/web/src/routes/index.tsx

Refactor (2) +16 / -1
use-project.tsReuse the shared agent endpoint configuration +2/-1

Reuse the shared agent endpoint configuration

• Moves agent URL ownership to the common API helper while preserving the existing export.

apps/web/src/components/editor/use-project.ts

agent.tsCentralize agent media and JSON requests +14/-0

Centralize agent media and JSON requests

• Adds shared agent URL, encoded media URL construction, and consistent HTTP error handling.

apps/web/src/lib/agent.ts

Tests (3) +284 / -0
project.test.tsTest media registration and export concurrency +124/-0

Test media registration and export concurrency

• Covers empty projects, measured analysis, clip source bounds, missing media, render sizing, single-worker claims, completion, and stale status reports.

apps/agent/test/project.test.ts

audio.test.tsTest real audio analysis algorithms +93/-0

Test real audio analysis algorithms

• Validates silence detection, room-tone thresholds, minimum gaps, peak envelopes, tempo recovery, and silent inputs.

apps/web/src/engine/audio.test.ts

compositor.test.tsTest timeline frame and audio selection +67/-0

Test timeline frame and audio selection

• Covers active clip boundaries, overlapping tracks, source-offset mapping, and audible clip filtering.

apps/web/src/engine/compositor.test.ts

Documentation (3) +1336 / -107
README.mdDocument the real-media editing and rendering architecture +349/-107

Document the real-media editing and rendering architecture

• Expands setup, tool, rendering, sandbox, testing, connector, and limitation documentation. Describes the OpenCut-derived engine and browser render lifecycle.

README.md

README.mdDocument landing page development and deployment +37/-0

Document landing page development and deployment

• Explains local serving, the sample-project timeline simulation, shared product colors, and Vercel deployment aliases.

site/README.md

index.htmlAdd an interactive EditAI landing page +950/-0

Add an interactive EditAI landing page

• Introduces a responsive static marketing page with an animated, arithmetic-correct ripple-delete demonstration, product architecture, capabilities, setup, sandbox, and review evidence. Includes reduced-motion support and scroll-driven timeline styling.

site/index.html

Other (5) +44 / -10
.gitignoreIgnore generated media and sample assets +4/-0

Ignore generated media and sample assets

• Excludes runtime media uploads and generated sample files alongside project and export state.

apps/agent/.gitignore

package.jsonAdd mediabunny media engine dependency +1/-0

Add mediabunny media engine dependency

• Adds mediabunny for WebCodecs-backed probing, decoding, muxing, and encoding.

apps/web/package.json

bun.lockLock mediabunny and refreshed TanStack packages +25/-10

Lock mediabunny and refreshed TanStack packages

• Records mediabunny and WebCodecs typings while refreshing related TanStack package resolutions.

bun.lock

.gitignoreIgnore local Vercel metadata +1/-0

Ignore local Vercel metadata

• Prevents generated Vercel project state from being committed.

site/.gitignore

vercel.jsonConfigure static site security headers +13/-0

Configure static site security headers

• Enables clean URLs and adds content-type and referrer-policy headers for the landing page.

site/vercel.json

@qodo-code-review

qodo-code-review Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (5) 📘 Rule violations (6) 📜 Skill insights (0)

Grey Divider


Action required

1. Active renders lose lease 🐞 Bug ☼ Reliability ⭐ New
Description
The worker refreshes its 60-second lease only from frame-progress callbacks, but renderTimeline
fully decodes and mixes audio before entering the frame loop that emits progress. A valid long
render can therefore appear abandoned during startup and be claimed by another editor while the
original worker is still active.
Code

apps/web/src/components/editor/use-render-worker.ts[R65-68]

+          fireAndForget(
+            agentJson(`/exports/${job.id}/progress`, {
+              method: "POST",
+              headers: { "content-type": "application/json" },
Relevance

●● Moderate

Lease-loss risk is plausible, but acceptance depends on intended heartbeat and rendering semantics.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The only client call that updates heartbeatAt is made inside onProgress. The exporter invokes
mixTimeline before starting its frame loop, while mixTimeline decodes whole source files and
constructs the complete timeline buffer; the server considers the claim stale after 60 seconds
without that callback.

apps/web/src/components/editor/use-render-worker.ts[54-75]
apps/web/src/engine/exporter.ts[107-142]
apps/web/src/engine/audio.ts[107-131]
apps/agent/src/project.ts[614-690]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Active workers can exceed the lease before the first frame-progress callback and be reclaimed prematurely.

## Issue Context
Start an independent periodic heartbeat immediately after a successful claim and keep it running through snapshot loading, audio mixing, encoding, uploads, and finalization. Stop it in `finally`; do not rely on percentage changes as liveness.

## Fix Focus Areas
- apps/web/src/components/editor/use-render-worker.ts[34-89]
- apps/agent/src/project.ts[614-690]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Claimed renders never recover ✓ Resolved 🐞 Bug ☼ Reliability
Description
Claiming permanently persists a job as rendering, but only pending jobs are discoverable or
claimable. If the rendering tab closes, crashes, or loses connectivity before reporting
failure/completion, the export remains stuck forever and can never be retried.
Code

apps/agent/src/project.ts[R581-583]

+      (r) => {
+        r.status = "rendering"
+        r.progress = 0
Relevance

●●● Strong

Reliability failure leaves claimed jobs unrecoverable; accepted history favors explicit operational
safeguards.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
claimExport changes the only claimable status to rendering; both server discovery and the
browser worker filter exclusively for pending, while failure is recorded only by the still-running
worker's catch path. There is no lease, timeout, or rendering-to-pending transition.

apps/agent/src/project.ts[559-603]
apps/agent/src/project.ts[633-645]
apps/web/src/components/editor/use-render-worker.ts[27-84]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A worker interruption leaves an export permanently stuck in `rendering`.

## Issue Context
Claims need an expiry/heartbeat or explicit recovery on startup so another editor can safely reclaim abandoned work.

## Fix Focus Areas
- apps/agent/src/project.ts[559-603]
- apps/web/src/components/editor/use-render-worker.ts[27-84]
- apps/agent/src/server.ts[124-160]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Export path escapes directory ✓ Resolved 🐞 Bug ⛨ Security
Description
POST /exports passes arbitrary resolution and format strings into requestExport, where they
become part of the output path. Values containing sufficient ../ components (for example
resolution: "../../../outside") normalize outside EXPORTS_DIR, and the subsequent upload moves
attacker-supplied bytes there.
Code

apps/agent/src/server.ts[R116-118]

+    const format = String(req.body?.format ?? "mp4")
+    const resolution = String(req.body?.resolution ?? "1080p")
+    res.json({ export: store.requestExport(format, resolution, EXPORTS_DIR) })
Relevance

●●● Strong

Direct untrusted path traversal is a concrete security defect; repository history accepts comparable
boundary hardening.

PR-#3

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The added endpoint stringifies untrusted request properties without enum validation. The project
store embeds both values in a joined path, and completion renames the upload to that stored path.

apps/agent/src/server.ts[114-118]
apps/agent/src/project.ts[519-536]
apps/agent/src/project.ts[611-618]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The REST export endpoint accepts path components in `format` and `resolution`, allowing the stored export target to escape the export directory.

## Issue Context
`requestExport` incorporates both fields into `rec.file`, and completion renames the uploaded file to that path.

## Fix Focus Areas
- apps/agent/src/server.ts[114-121]
- apps/agent/src/project.ts[511-540]
- apps/agent/src/project.ts[611-618]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View high (4)
4. Source frames alias by name ✓ Resolved 🐞 Bug ≡ Correctness
Description
Preview and export store decoded frames by media name even though separate active clips of the same
file can have different source offsets. The last insertion replaces the other clip's frame, so
overlapping instances can composite the wrong timestamp—particularly when project insertion order
differs from track stacking order.
Code

apps/web/src/engine/exporter.ts[R117-118]

+        const frame = await cache.getFrameAt(clip.name, sourceTimeFor(clip, time)).catch(() => null)
+        if (frame) frames.set(clip.name, frame.canvas)
Relevance

●● Moderate

The aliasing risk is substantial and plausible, but depends on compositor frame ownership and clip
overlap semantics.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Clips have independent IDs and source offsets, but both producers insert frames with clip.name and
the compositor retrieves with the same key. Track order determines painting independently of project
clip insertion order, so the overwritten value is not guaranteed to belong to the topmost clip.

apps/web/src/components/editor/data.ts[3-15]
apps/web/src/engine/compositor.ts[3-17]
apps/web/src/engine/compositor.ts[34-46]
apps/web/src/engine/exporter.ts[113-120]
apps/web/src/components/editor/use-preview.ts[58-69]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Frames for multiple active clips using one media file overwrite each other in the compositor input map.

## Issue Context
Keep decoder caches keyed by media name, but key each composited frame by clip ID so independent source offsets remain distinct.

## Fix Focus Areas
- apps/web/src/engine/compositor.ts[3-46]
- apps/web/src/engine/exporter.ts[114-120]
- apps/web/src/components/editor/use-preview.ts[58-69]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Exports buffer entirely in memory ✓ Resolved 🐞 Bug ☼ Reliability
Description
The exporter muxes into BufferTarget, retains the complete output buffer, wraps it in a Blob, and
only then starts the upload. Large 1080p/4K jobs therefore require the whole encoded video in
browser memory and can exhaust the tab before the server's streaming upload path is reached.
Code

apps/web/src/engine/exporter.ts[R78-81]

+  const output = new Output({
+    format: format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(),
+    target: new BufferTarget(),
+  })
Relevance

●● Moderate

Memory pressure is plausible, but streaming requires a substantial exporter/API redesign and history
provided no close precedent.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The output target is explicitly a BufferTarget, and the function cannot return until
output.target.buffer contains the entire finalized file. The worker then uploads that completed
Blob, despite the server-side implementation being designed to stream large renders to disk and
comments acknowledging that 4K output may be gigabytes.

apps/web/src/engine/exporter.ts[78-81]
apps/web/src/engine/exporter.ts[125-134]
apps/web/src/components/editor/use-render-worker.ts[58-62]
apps/agent/src/project.ts[605-617]
apps/agent/src/server.ts[154-160]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The browser retains the complete encoded export before uploading it, making large renders prone to out-of-memory failures.

## Issue Context
Use a streaming/chunked mediabunny target and upload pipeline so encoded bytes are released as they are produced; preserve cancellation and atomic server finalization.

## Fix Focus Areas
- apps/web/src/engine/exporter.ts[78-81]
- apps/web/src/engine/exporter.ts[125-134]
- apps/web/src/components/editor/use-render-worker.ts[58-62]
- apps/agent/src/server.ts[154-160]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Suffix ranges return wrong bytes ✓ Resolved 🐞 Bug ≡ Correctness
Description
The media handler interprets suffix ranges such as Range: bytes=-N as bytes 0-N, returning the
beginning of the file instead of its final N bytes. Clients and decoders relying on HTTP
suffix-range semantics can consequently fail to probe, seek, resume, or decode imported media
correctly.
Code

apps/agent/src/server.ts[R93-94]

+      const start = range[1] ? Number(range[1]) : 0
+      const end = range[2] ? Math.min(Number(range[2]), size - 1) : size - 1
Relevance

●● Moderate

Concrete HTTP correctness issue, but no close repository precedent for range parsing was found.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The range regex permits an empty first-byte position, but the subsequent omitted-start branch
converts it to zero and treats the suffix length as an absolute end offset before streaming the
interval. Because the browser engine opens these files through a range-backed UrlSource, these
incorrect responses directly affect media probing, seeking, and decoding.

apps/agent/src/server.ts[91-102]
apps/web/src/engine/media.ts[17-27]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The media endpoint handles suffix byte ranges incorrectly: `bytes=-N` is treated as `bytes=0-N` instead of returning the final N bytes of the file.

## Issue Context
Support all single-range forms (`start-end`, `start-`, and `-suffixLength`) and return 416 for invalid or zero-length ranges. The regex intentionally accepts an omitted range start, so the omitted-start branch must calculate the start relative to the file size.

## Fix Focus Areas
- apps/agent/src/server.ts[91-102]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Exports use mutable project state ✓ Resolved 🐞 Bug ≡ Correctness
Description
A queued export stores dimensions and duration but not the requested project snapshot; when a worker
later sees the job, it renders whatever project happens to be current. Edits made while no editor is
available—or before a reconnect observes the job—therefore change the queued output and can make its
recorded duration disagree with the actual file.
Code

apps/web/src/components/editor/use-render-worker.ts[R80-83]

+    const job = project.exports?.find((e) => e.status === "pending" && !attempted.has(e.id))
+    if (!job) return
+    attempted.add(job.id)
+    void run(job, project)
Relevance

●● Moderate

Snapshot semantics are architectural and correctness-sensitive; no close accepted or rejected
precedent was found.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
requestExport copies only metadata such as durationSeconds, while useRenderWorker passes the
current SSE project object to run. SSE always publishes the current store state, so a delayed or
reconnecting worker has no access to the project state that existed when the export was requested.

apps/agent/src/project.ts[511-540]
apps/web/src/components/editor/use-render-worker.ts[27-45]
apps/web/src/components/editor/use-render-worker.ts[78-84]
apps/web/src/components/editor/use-project.ts[18-28]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Queued jobs render the latest project rather than the project state at queue time.

## Issue Context
Store an immutable project snapshot or revision-bound render manifest with each job, and have the worker fetch/render that exact state.

## Fix Focus Areas
- apps/agent/src/project.ts[511-540]
- apps/web/src/components/editor/use-render-worker.ts[27-45]
- apps/web/src/components/editor/use-render-worker.ts[78-83]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

8. Merge sandbox description literals 📘 Rule violation ⚙ Maintainability ⭐ New
Description
The connector description concatenates two static string literals even though a single literal would
produce the same value. This violates the requirement to eliminate redundant static string
concatenation.
Code

apps/agent/scripts/setup.ts[R189-190]

+        "Dockerized media workbench: list and probe media files, run ffmpeg renders, and script " +
+        "glue work with python. No network access; paths are relative to its workspace.",
Relevance

●●● Strong

Trivial static-literal cleanup directly matches an active maintainability rule.

PR-#3

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The cited changed lines show two string literals joined solely by +, while the cited rule
prohibits static-only concatenation.

Rule 2993243: Avoid redundant string concatenation when a single literal suffices
apps/agent/scripts/setup.ts[189-190]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The ffmpeg sandbox description is split across two static string literals joined with `+`, although no dynamic value is interpolated.

## Issue Context
PR Compliance ID 2993243 requires contiguous static strings to be represented as one literal.

## Fix Focus Areas
- apps/agent/scripts/setup.ts[189-190]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Expand multiline sandbox ternary 📘 Rule violation ⚙ Maintainability ⭐ New
Description
The ffmpeg attachment status is selected with a ternary expression spread across several lines. The
checklist requires multiline ternaries to be rewritten with clearer if/else control flow.
Code

apps/agent/scripts/setup.ts[R319-321]

+    ffmpegSandbox
+      ? `attached from ${FFMPEG_SANDBOX_URL}`
+      : "not running (cd packages/ffmpeg-sandbox && bun run build:image && bun run start), skipped"
Relevance

●●● Strong

Explicit multiline ternary violation matches the stated maintainability rule.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The cited changed lines contain the ffmpegSandbox ? ... : ... branches across multiple lines,
which directly matches the rule's prohibited multiline ternary form.

Rule 2993282: Restrict ternary operators to simple value selection expressions
apps/agent/scripts/setup.ts[318-322]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The ffmpeg sandbox status message uses a multiline conditional expression.

## Issue Context
PR Compliance ID 2993282 restricts ternaries to simple value selections and explicitly treats expressions spanning multiple lines as violations.

## Fix Focus Areas
- apps/agent/scripts/setup.ts[318-322]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Frame epsilon loses precision 📘 Rule violation ≡ Correctness ⭐ New
Description
The frame-count adjustment uses the decimal literal 1e-9, which is not exactly representable as an
IEEE 754 JavaScript number. The checklist requires an exact representation rather than a
precision-losing numeric literal.
Code

apps/web/src/engine/exporter.ts[129]

+    const frameCount = Math.max(1, Math.ceil(project.duration * fps - 1e-9))
Relevance

●●● Strong

The finding directly cites an active precision-literal rule and requires a simple local change.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The cited changed line uses 1e-9 directly in JavaScript arithmetic; the compliance rule prohibits
non-exact decimal literals in IEEE 754 number contexts.

Rule 2993559: Avoid precision-losing numeric literals in IEEE 754 contexts
apps/web/src/engine/exporter.ts[129-129]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The frame-count calculation directly subtracts the non-exact binary64 literal `1e-9`.

## Issue Context
PR Compliance ID 2993559 disallows decimal literals that are not exactly representable in IEEE 754 contexts. Preserve the intended whole-frame boundary handling with integer/scaled arithmetic or another exact formulation.

## Fix Focus Areas
- apps/web/src/engine/exporter.ts[126-129]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (18)
11. Abandoned job never reclaimed by same tab 🐞 Bug ☼ Reliability ⭐ New
Description
The module-level attempted Set in use-render-worker.ts permanently excludes an export ID after a
tab attempts it, preventing retries when a lost 409 claim later becomes reclaimable after lease
expiry. Because project reset clears exports and ID allocation then reuses IDs starting at exp1,
the same blacklist can also suppress newly queued exports until the editor is reloaded or a fresh
tab/session handles them.
Code

apps/web/src/components/editor/use-render-worker.ts[R93-97]

+    if (busy.current) return
+    const job = project.exports?.find((e) => isClaimable(e) && !attempted.has(e.id))
+    if (!job) return
+    attempted.add(job.id)
+    fireAndForget(run(job))
Relevance

●●● Strong

Permanent retry suppression after failed claims is a concrete local worker bug.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The effect adds job.id to the module-level attempted Set before calling run(job), and the
selection predicate permanently excludes IDs already in that Set; if claiming returns 409, run
exits early without removing the ID even though isClaimable later permits reclaiming an expired
rendering job. Separately, reset clears the export list and ID allocation derives the next ID
solely from that current list, so the first post-reset export reuses exp1 and is incorrectly
treated as an old attempt.

apps/web/src/components/editor/use-render-worker.ts[41-43]
apps/web/src/components/editor/use-render-worker.ts[92-97]
apps/web/src/components/editor/exports.ts[9-14]
apps/web/src/components/editor/use-render-worker.ts[14-24]
apps/agent/src/project.ts[181-185]
apps/agent/src/project.ts[523-529]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Fix the page-global `attempted` Set so it does not permanently blacklist an export ID after a lost 409 claim or mistake a newly created post-reset export for an earlier attempt when IDs are reused.

## Issue Context
`isClaimable` allows an abandoned `rendering` job to be reclaimed after `RENDER_LEASE_MS`, but the tab that encountered the 409 cannot select it again because its ID remains in `attempted`. Reset also clears the export list, while ID allocation derives the next ID from that current list and can therefore reuse `exp1`; preserve React remount protection without suppressing legitimately reclaimable or newly created jobs, for example by using a non-reusable job identity, keying attempts by immutable creation/claim identity, or removing entries when jobs disappear or become eligible for another attempt.

## Fix Focus Areas
- apps/web/src/components/editor/use-render-worker.ts[14-97]
- apps/agent/src/project.ts[181-185]
- apps/agent/src/project.ts[523-529]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


12. Render endpoints lack claim authorization 🐞 Bug ☼ Reliability ⭐ New
Description
The /exports/:id/chunk, /exports/:id/finish, and /exports/:id/failed routes identify attempts
only by export ID and do not verify that the caller holds the current claim or that the export is
still rendering. After a lease is reclaimed, a stale, duplicate, or unrelated client can corrupt the
shared .render file, delete the replacement attempt's partial output, finalize stale output, or
mark another worker's active or completed render as failed.
Code

apps/agent/src/server.ts[R180-192]

+app.post("/exports/:id/chunk", async (req, res) => {
+  try {
+    const rec = store.getExport(req.params.id)
+    const position = Number((req.query as Record<string, string | undefined>).position)
+    if (!Number.isInteger(position) || position < 0) throw new Error("A non-negative integer ?position= is required.")
+    const data = await readBody(req, CHUNK_LIMIT)
+    if (data.length === 0) throw new Error("Chunk was empty.")
+    writeChunkAt(renderPath(rec.id), position, data)
+    res.json({ ok: true, position, bytes: data.length })
+  } catch (err) {
+    fail(res, 400, err)
+  }
+})
Relevance

●● Moderate

Claim authorization is a substantial reliability redesign without closely matching historical
evidence.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
claimExport records timestamps and returns no unique attempt or lease token that subsequent
requests can present, while the mutation routes operate only on the job ID. The chunk route writes
unconditionally to a shared path regardless of export status, /failed unconditionally removes that
path before changing status, and completeExport checks neither rendering state nor claimant
identity, proving that a worker resuming after another claim remains able to write, finish, or fail
the export.

apps/agent/src/server.ts[180-192]
apps/agent/src/project.ts[591-606]
apps/agent/src/project.ts[591-605]
apps/agent/src/server.ts[151-156]
apps/agent/src/server.ts[180-198]
apps/agent/src/project.ts[636-665]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Reclaimed render attempts are not fenced: `/exports/:id/chunk`, `/exports/:id/finish`, and `/exports/:id/failed` accept mutations without verifying that the caller holds the current claim or that the export remains in the expected `rendering` state. This allows stale workers or unrelated clients to mutate the shared partial file and job status.

## Issue Context
Generate a unique attempt or lease token for every successful claim and return it to the render worker. Require that token for progress, chunk, failed, and finish operations, reject requests when it no longer matches the current claim, and verify that the export is still rendering before changing files or status.

## Fix Focus Areas
- apps/agent/src/project.ts[591-674]
- apps/agent/src/server.ts[128-202]
- apps/web/src/components/editor/use-render-worker.ts[34-85]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


13. completeExport skips status validation 🐞 Bug ≡ Correctness ⭐ New
Description
completeExport renames the uploaded file and marks the export done without ever checking that
the record's current status is rendering, so it can finalize a pending or already-failed
export if a file happens to exist at the render path. This is a state-integrity gap distinct from
the already-fixed late-report protections on setExportProgress/failExport.
Code

apps/agent/src/project.ts[R636-648]

+  completeExport(id: string, uploadedPath: string) {
+    const rec = this.getExport(id)
+    if (!existsSync(uploadedPath)) throw new Error(`No uploaded file at ${uploadedPath}.`)
+    // Moved before the commit: commit notifies subscribers synchronously, and a client that
+    // reacts to the finished export must not find the file missing.
+    mkdirSync(dirname(rec.file), { recursive: true })
+    renameSync(uploadedPath, rec.file)
+    const sizeBytes = statSync(rec.file).size
+    // The snapshot exists so an abandoned render can be retried faithfully. Done is terminal,
+    // so it has nothing left to serve.
+    const snapshot = snapshotPath(rec)
+    if (existsSync(snapshot)) unlinkSync(snapshot)
+    return this.updateExport(
Relevance

●● Moderate

State validation is a plausible correctness gap, but no closely matching project precedent was
found.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
completeExport only checks existsSync(uploadedPath), never rec.status, before performing the rename
and committing status 'done'.

apps/agent/src/project.ts[636-660]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`completeExport` finalizes an export without checking that its status is currently `rendering`, allowing a pending or failed export to be marked done if a file exists at the expected render path.

## Issue Context
Other terminal-state guards exist (e.g. failExport checks for 'done', setExportProgress checks for 'rendering'), but completeExport has none.

## Fix Focus Areas
- apps/agent/src/project.ts[636-660]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


14. Export button allows duplicate queued renders 🐞 Bug ≡ Correctness ⭐ New
Description
TopBar's Export button is disabled only while the local tab's render state is non-null; it does
not check whether an export for this project is already pending or rendering on the agent, and
requestExport never deduplicates against an existing unfinished job. Repeated clicks, or a click
from a second tab while a render is already queued/in-progress, create multiple duplicate export
jobs each consuming a full render.
Code

apps/web/src/routes/index.tsx[R39-47]

+  const queueExport = useCallback(() => {
+    fireAndForget(
+      agentJson("/exports", {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({ format: "mp4", resolution: "1080p" }),
+      }),
+    )
+  }, [])
Relevance

●● Moderate

Duplicate-job prevention is a product-level behavior change without closely matching historical
evidence.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
queueExport unconditionally POSTs /exports on each call; requestExport in project.ts always creates
a new ExportRecord with no check for an existing pending/rendering job for the project, and the
button's disabled condition only reflects this tab's local render state.

apps/web/src/routes/index.tsx[39-47]
apps/agent/src/project.ts[514-539]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Clicking Export repeatedly, or from multiple tabs, can queue multiple duplicate render jobs because neither the UI nor requestExport checks for an existing pending/rendering export before creating a new one.

## Issue Context
canExport/queueExport in apps/web/src/routes/index.tsx and the Export button's disabled logic in apps/web/src/components/editor/top-bar.tsx only consider the local tab's render state, not project.exports' pending/rendering entries; apps/agent/src/project.ts's requestExport has no dedup check.

## Fix Focus Areas
- apps/web/src/routes/index.tsx[39-47]
- apps/agent/src/project.ts[514-539]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


15. File input lacks label ✓ Resolved 📘 Rule violation ☑ Accessibility
Description
The new hidden file input has no discernible label or programmatic association with the visible
Import trigger. Assistive-technology users cannot identify the form control.
Code

apps/web/src/components/editor/side-panel.tsx[R105-110]

+      <input
+        ref={input}
+        type="file"
+        accept="video/*,audio/*"
+        multiple
+        className="sr-only"
Relevance

●●● Strong

Explicit accessibility rule violation and deterministic label fix; likely accepted despite no
matching historical suggestion.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 2992621 requires form controls to have discernible text and a programmatic label association.
The added input type="file" has neither a label nor an accessible-name attribute.

Rule 2992621: Labels must contain discernible text and be programmatically associated with a form control
apps/web/src/components/editor/side-panel.tsx[97-110]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The hidden media file input has no accessible, discernible label.

## Issue Context
The visible `Import` button triggers the input programmatically but does not label it.

## Fix Focus Areas
- apps/web/src/components/editor/side-panel.tsx[97-110]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


16. Failed renders leak encoders ✓ Resolved 🐞 Bug ☼ Reliability
Description
Exceptions after output.start() but before normal finalization skip audioSource.close(),
videoSource.close(), and output.cancel(); the finally block only clears the frame cache.
Failed frame/audio encodes therefore leave the WebCodecs/muxer resources active, causing
memory/resource leakage and potentially disrupting later renders.
Code

apps/web/src/engine/exporter.ts[R127-129]

+  } finally {
+    cache.clear()
+  }
Relevance

●●● Strong

Missing cleanup on exceptions is a concrete resource-leak path with a localized, conventional
finally cleanup fix.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The output is started before audio/frame work, cancellation is only performed in the explicit abort
branch, and the exception cleanup contains only cache.clear().

apps/web/src/engine/exporter.ts[95-111]
apps/web/src/engine/exporter.ts[117-129]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`renderTimeline` cleans up the video cache on an ordinary render exception but does not close sources or cancel the started output.

## Issue Context
Cancellation is currently limited to the explicit abort branch; failures from audio encoding, frame decoding, canvas encoding, or finalization bypass it.

## Fix Focus Areas
- apps/web/src/engine/exporter.ts[95-111]
- apps/web/src/engine/exporter.ts[117-129]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


17. Dimension lookup nests ternaries ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
sourceWidth and sourceHeight use chained nested ternary operators across multiple branches. The
checklist restricts ternaries to simple value selection and requires clearer if/else control flow
here.
Code

apps/web/src/engine/compositor.ts[R58-61]

+const sourceWidth = (s: CanvasImageSource) =>
+  "videoWidth" in s ? s.videoWidth : "naturalWidth" in s ? s.naturalWidth : "width" in s ? Number(s.width) : 0
+const sourceHeight = (s: CanvasImageSource) =>
+  "videoHeight" in s ? s.videoHeight : "naturalHeight" in s ? s.naturalHeight : "height" in s ? Number(s.height) : 0
Relevance

●●● Strong

Direct violation of an explicit active style rule; converting nested ternaries is a deterministic
local fix.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 2993282 prohibits ternary branches containing another ternary. Both new helpers chain multiple
conditional operators.

Rule 2993282: Restrict ternary operators to simple value selection expressions
apps/web/src/engine/compositor.ts[58-61]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Canvas source dimensions are selected through nested ternary chains.

## Issue Context
Rewrite the property checks as helper bodies with early returns or `if/else` branches.

## Fix Focus Areas
- apps/web/src/engine/compositor.ts[58-61]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


18. Editor actions use void ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The editor uses void to discard export and import promises in callbacks. The checklist prohibits
this operator in changed JavaScript and TypeScript.
Code

apps/web/src/routes/index.tsx[39]

+    void agentJson("/exports", {
Relevance

●●● Strong

The finding directly cites an explicit repository rule, and replacing discarded promise expressions
is a simple callback adjustment.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 2993324 disallows runtime void expressions. The editor adds void agentJson(...), `void
importFiles(...), and an inline callback using void importFiles(files)`.

Rule 2993324: Avoid using the JavaScript void operator
apps/web/src/routes/index.tsx[39-43]
apps/web/src/routes/index.tsx[90-106]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Editor export and import callbacks use the prohibited `void` operator.

## Issue Context
Preserve or add explicit rejection handling rather than suppressing promise usage with `void`.

## Fix Focus Areas
- apps/web/src/routes/index.tsx[39-43]
- apps/web/src/routes/index.tsx[90-106]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


19. Caption drawing uses forEach ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
Caption lines are iterated with forEach solely to mutate the canvas via fillText. The rule
requires for...of for side-effect-driven iteration.
Code

apps/web/src/engine/compositor.ts[78]

+  lines.forEach((line, i) => ctx.fillText(line, width / 2, baseline + i * lineHeight))
Relevance

●●● Strong

The stated side-effect loop rule directly matches this simple callback; conversion is a
straightforward style change.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 2993122 prefers for...of for side effects. The callback only calls ctx.fillText(...) and
does not produce a transformed collection.

Rule 2993122: Prefer for...of loops over Array.forEach for iteration with side effects
apps/web/src/engine/compositor.ts[78-78]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Caption rendering uses `Array.forEach` solely for canvas side effects.

## Issue Context
Use an indexed `for...of` loop so iteration and side effects are explicit.

## Fix Focus Areas
- apps/web/src/engine/compositor.ts[78-78]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


20. return() rejection is unhandled ✓ Resolved 📘 Rule violation ☼ Reliability
Description
clear() prefixes the async iterator's return() call with JavaScript's void operator,
intentionally discarding its Promise without awaiting it or attaching a rejection handler. This
violates the checklist and allows cleanup failures to surface as unhandled Promise rejections.
Code

apps/web/src/engine/video-cache.ts[58]

+      void this.sinks.get(key)?.iterator?.return()
Relevance

●●● Strong

Discarding an async iterator cleanup Promise directly matches the stated compliance rules and has a
localized fix.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2993980 requires every Promise-like result to be awaited, returned, or given an
explicit rejection handler, while rule 2993324 prohibits this runtime use of void. The added `void
this.sinks.get(key)?.iterator?.return()` expression discards the Promise returned by
AsyncGenerator.return() without any rejection handler, directly demonstrating both violations.

Rule 2993980: Handle all Promise-like results explicitly (await, return, or attach rejection handler)
Rule 2993324: Avoid using the JavaScript void operator
apps/web/src/engine/video-cache.ts[55-58]
apps/web/src/engine/video-cache.ts[58-58]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Cache cleanup uses the prohibited `void` operator to discard the Promise returned by the async iterator's `return()`, allowing cleanup failures to become unhandled rejections.

## Issue Context
`clear()` is synchronous. Invoke the cleanup directly and explicitly handle any rejected Promise with `.catch(...)`, or make cleanup asynchronous and ensure all callers await it.

## Fix Focus Areas
- apps/web/src/engine/video-cache.ts[58-58]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


21. usePreview uses void ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The new preview hook prefixes asynchronous rendering and audio calls with the JavaScript void
operator. This directly violates the rule and can obscure promise handling.
Code

apps/web/src/components/editor/use-preview.ts[75]

+        if (next !== null && next !== at) void draw(next)
Relevance

●●● Strong

Direct violation of an explicit active repository rule; replacing void is trivial and deterministic.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 2993324 disallows prefixing runtime expressions with void; the hook adds void draw(...) and
void startAudio(...) calls.

Rule 2993324: Avoid using the JavaScript void operator
apps/web/src/components/editor/use-preview.ts[75-82]
apps/web/src/components/editor/use-preview.ts[132-153]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Several asynchronous preview calls discard promises using the prohibited `void` operator.

## Issue Context
Handle rejected promises explicitly or invoke the calls without `void` where rejection is already contained.

## Fix Focus Areas
- apps/web/src/components/editor/use-preview.ts[75-82]
- apps/web/src/components/editor/use-preview.ts[132-153]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


22. Site builder uses forEach 📘 Rule violation ⚙ Maintainability
Description
The new site builder uses nested forEach callbacks with multiple DOM mutations and array pushes.
These side-effect-heavy loops violate the required for...of style.
Code

site/index.html[R790-793]

+    Object.keys(LANES).forEach(function(id){
+      var lane = document.querySelector('[data-lane="' + id + '"]');
+      var spec = LANES[id];
+      spec.clips.forEach(function(c){
Relevance

●●● Strong

Nested mutation-heavy callbacks directly match the stated for-of rule; the required refactor is
mechanical.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence

[Comment truncated to fit github's 65,536-char limit.]

Comment thread apps/web/src/components/editor/side-panel.tsx
Comment thread apps/web/src/components/editor/use-preview.ts Outdated
Comment thread apps/web/src/components/editor/use-render-worker.ts Outdated
Comment thread apps/web/src/routes/index.tsx Outdated
Comment thread apps/web/src/engine/compositor.ts Outdated
Comment thread apps/web/src/components/editor/use-preview.ts Outdated
Comment thread apps/agent/src/server.ts
Comment thread apps/web/src/engine/exporter.ts
Comment thread apps/web/src/engine/exporter.ts Outdated
Comment thread apps/web/src/components/editor/use-preview.ts
deonmenezes and others added 2 commits August 29, 2026 19:28
Qodo raised ten bugs and ten rule violations. Eighteen were real.

Security. POST /exports took format and resolution as free strings and made
them path components, so "../../../outside" escaped the exports directory and
the upload wrote attacker-supplied bytes there. Both are now checked against
the allowed sets where the path is built rather than only at the MCP edge,
which zod already guarded, and the project name is slugged for the same reason.

Reliability. A claimed render could never be recovered: only the worker holding
it could report failure, so a closed tab stranded the job in rendering forever.
Claims now carry a sixty second lease that progress refreshes, and an expired
one is claimable again. A reclaim also clears the abandoned partial first,
which the review did not raise: writing a fresh encode over a longer previous
attempt leaves its trailing bytes behind, which is a corrupt file rather than a
retry.

Memory. The encode buffered the whole output before uploading, so a long 4K
render could exhaust the tab before a byte reached the server. It now streams
through mediabunny's StreamTarget in 8 MiB chunks to positional writes on the
server, and the muxer is back-pressured by the upload rather than queueing.

Correctness. Renders took the live project, so an edit landing between approval
and claim silently changed what was encoded; the timeline is now snapshotted
beside the job and workers render that. Frames were keyed by media name, so two
clips of one file at different offsets overwrote each other. bytes=-500 was
read as the first 500 bytes rather than the last, which is where a non
faststart mp4 keeps its index. Frame count came from Math.round, leaving the
video up to half a frame longer or shorter than its own audio. Playback
restarted on every project broadcast, which during a render is every two
percent and audible. Pausing during the mixdown left audio playing over a
paused preview.

Encoders leaked on any failure after start: the finally block cleared the frame
cache but left the video and audio sources and the muxer open, which can break
the next render.

Style, against the repo's own checklist: the void operator is gone in favour of
a helper that also stops discarded rejections surfacing unhandled, forEach and
nested ternaries are replaced, and the file input has a label.

Two findings are not taken. The caption constants are not a precision-loss
violation: that rule is about literals that cannot round-trip, and 0.055 does.
The landing page's forEach and function expressions are real, but that script
came from an earlier commit that only rides along in this branch, and rewriting
197 lines of untested animation code has nothing to do with the renderer.

Verified again end to end after the rewrite of the upload path: a streamed
1080p render is valid H.264 and AAC with no leftover temp files, a suffix range
returns the tail of the file, and an abandoned render was reclaimed after its
lease expired and completed to a valid 720p file.

Tests 43 in the agent, 20 in the editor, 8 in the merge gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8rspEEzFARv7H1opU9ER2
The video-editing skill told the agent all media work happens through the
ffmpeg-sandbox connector, but nothing ever created that connector: the
package had no manifest, sat outside the workspace, and the README asked
for manual clicks in TrueForge settings.

Now packages/ffmpeg-sandbox is a workspace package with build:image and
start scripts, and setup.ts probes its /health, registers it with the
harness, and attaches it to the agent deferred with @destructive approval
on run_python, the same gate the timeline's delete tools get. Down means
skipped with a hint, not registered broken.

Verified end to end: a containerized 9:16 crop rendered and probed through
MCP, the harness lists all four tools, setup reruns clean, and a live turn
answered "6 clips, 24 seconds", which matches the store. Docs and the tool
table now match the code: 19 tools, real renders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYYfW5NnuCUx6sNk2GoA7V
@deonmenezes

Copy link
Copy Markdown
Owner Author

/review

Comment on lines +189 to +190
"Dockerized media workbench: list and probe media files, run ffmpeg renders, and script " +
"glue work with python. No network access; paths are relative to its workspace.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Merge sandbox description literals 📘 Rule violation ⚙ Maintainability

The connector description concatenates two static string literals even though a single literal would
produce the same value. This violates the requirement to eliminate redundant static string
concatenation.
Agent Prompt
## Issue description
The ffmpeg sandbox description is split across two static string literals joined with `+`, although no dynamic value is interpolated.

## Issue Context
PR Compliance ID 2993243 requires contiguous static strings to be represented as one literal.

## Fix Focus Areas
- apps/agent/scripts/setup.ts[189-190]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +319 to +321
ffmpegSandbox
? `attached from ${FFMPEG_SANDBOX_URL}`
: "not running (cd packages/ffmpeg-sandbox && bun run build:image && bun run start), skipped"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Expand multiline sandbox ternary 📘 Rule violation ⚙ Maintainability

The ffmpeg attachment status is selected with a ternary expression spread across several lines. The
checklist requires multiline ternaries to be rewritten with clearer if/else control flow.
Agent Prompt
## Issue description
The ffmpeg sandbox status message uses a multiline conditional expression.

## Issue Context
PR Compliance ID 2993282 restricts ternaries to simple value selections and explicitly treats expressions spanning multiple lines as violations.

## Fix Focus Areas
- apps/agent/scripts/setup.ts[318-322]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

// Ceil, not round: a timeline that is not a whole number of frames long must still be
// covered to its end, and the final frame is shortened so the video lasts exactly as long
// as the audio rather than up to half a frame more.
const frameCount = Math.max(1, Math.ceil(project.duration * fps - 1e-9))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Frame epsilon loses precision 📘 Rule violation ≡ Correctness

The frame-count adjustment uses the decimal literal 1e-9, which is not exactly representable as an
IEEE 754 JavaScript number. The checklist requires an exact representation rather than a
precision-losing numeric literal.
Agent Prompt
## Issue description
The frame-count calculation directly subtracts the non-exact binary64 literal `1e-9`.

## Issue Context
PR Compliance ID 2993559 disallows decimal literals that are not exactly representable in IEEE 754 contexts. Preserve the intended whole-frame boundary handling with integer/scaled arithmetic or another exact formulation.

## Fix Focus Areas
- apps/web/src/engine/exporter.ts[126-129]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +65 to +68
fireAndForget(
agentJson(`/exports/${job.id}/progress`, {
method: "POST",
headers: { "content-type": "application/json" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Active renders lose lease 🐞 Bug ☼ Reliability

The worker refreshes its 60-second lease only from frame-progress callbacks, but renderTimeline
fully decodes and mixes audio before entering the frame loop that emits progress. A valid long
render can therefore appear abandoned during startup and be claimed by another editor while the
original worker is still active.
Agent Prompt
## Issue description
Active workers can exceed the lease before the first frame-progress callback and be reclaimed prematurely.

## Issue Context
Start an independent periodic heartbeat immediately after a successful claim and keep it running through snapshot loading, audio mixing, encoding, uploads, and finalization. Stop it in `finally`; do not rely on percentage changes as liveness.

## Fix Focus Areas
- apps/web/src/components/editor/use-render-worker.ts[34-89]
- apps/agent/src/project.ts[614-690]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread apps/agent/src/server.ts
Comment on lines +180 to +192
app.post("/exports/:id/chunk", async (req, res) => {
try {
const rec = store.getExport(req.params.id)
const position = Number((req.query as Record<string, string | undefined>).position)
if (!Number.isInteger(position) || position < 0) throw new Error("A non-negative integer ?position= is required.")
const data = await readBody(req, CHUNK_LIMIT)
if (data.length === 0) throw new Error("Chunk was empty.")
writeChunkAt(renderPath(rec.id), position, data)
res.json({ ok: true, position, bytes: data.length })
} catch (err) {
fail(res, 400, err)
}
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. Render endpoints lack claim authorization 🐞 Bug ☼ Reliability

The /exports/:id/chunk, /exports/:id/finish, and /exports/:id/failed routes identify attempts
only by export ID and do not verify that the caller holds the current claim or that the export is
still rendering. After a lease is reclaimed, a stale, duplicate, or unrelated client can corrupt the
shared .render file, delete the replacement attempt's partial output, finalize stale output, or
mark another worker's active or completed render as failed.
Agent Prompt
## Issue description
Reclaimed render attempts are not fenced: `/exports/:id/chunk`, `/exports/:id/finish`, and `/exports/:id/failed` accept mutations without verifying that the caller holds the current claim or that the export remains in the expected `rendering` state. This allows stale workers or unrelated clients to mutate the shared partial file and job status.

## Issue Context
Generate a unique attempt or lease token for every successful claim and return it to the render worker. Require that token for progress, chunk, failed, and finish operations, reject requests when it no longer matches the current claim, and verify that the export is still rendering before changing files or status.

## Fix Focus Areas
- apps/agent/src/project.ts[591-674]
- apps/agent/src/server.ts[128-202]
- apps/web/src/components/editor/use-render-worker.ts[34-85]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread apps/agent/src/project.ts
Comment on lines +636 to +648
completeExport(id: string, uploadedPath: string) {
const rec = this.getExport(id)
if (!existsSync(uploadedPath)) throw new Error(`No uploaded file at ${uploadedPath}.`)
// Moved before the commit: commit notifies subscribers synchronously, and a client that
// reacts to the finished export must not find the file missing.
mkdirSync(dirname(rec.file), { recursive: true })
renameSync(uploadedPath, rec.file)
const sizeBytes = statSync(rec.file).size
// The snapshot exists so an abandoned render can be retried faithfully. Done is terminal,
// so it has nothing left to serve.
const snapshot = snapshotPath(rec)
if (existsSync(snapshot)) unlinkSync(snapshot)
return this.updateExport(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

6. Completeexport skips status validation 🐞 Bug ≡ Correctness

completeExport renames the uploaded file and marks the export done without ever checking that
the record's current status is rendering, so it can finalize a pending or already-failed
export if a file happens to exist at the render path. This is a state-integrity gap distinct from
the already-fixed late-report protections on setExportProgress/failExport.
Agent Prompt
## Issue description
`completeExport` finalizes an export without checking that its status is currently `rendering`, allowing a pending or failed export to be marked done if a file exists at the expected render path.

## Issue Context
Other terminal-state guards exist (e.g. failExport checks for 'done', setExportProgress checks for 'rendering'), but completeExport has none.

## Fix Focus Areas
- apps/agent/src/project.ts[636-660]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +93 to +97
if (busy.current) return
const job = project.exports?.find((e) => isClaimable(e) && !attempted.has(e.id))
if (!job) return
attempted.add(job.id)
fireAndForget(run(job))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

7. Abandoned job never reclaimed by same tab 🐞 Bug ☼ Reliability

The module-level attempted Set in use-render-worker.ts permanently excludes an export ID after a
tab attempts it, preventing retries when a lost 409 claim later becomes reclaimable after lease
expiry. Because project reset clears exports and ID allocation then reuses IDs starting at exp1,
the same blacklist can also suppress newly queued exports until the editor is reloaded or a fresh
tab/session handles them.
Agent Prompt
## Issue description
Fix the page-global `attempted` Set so it does not permanently blacklist an export ID after a lost 409 claim or mistake a newly created post-reset export for an earlier attempt when IDs are reused.

## Issue Context
`isClaimable` allows an abandoned `rendering` job to be reclaimed after `RENDER_LEASE_MS`, but the tab that encountered the 409 cannot select it again because its ID remains in `attempted`. Reset also clears the export list, while ID allocation derives the next ID from that current list and can therefore reuse `exp1`; preserve React remount protection without suppressing legitimately reclaimable or newly created jobs, for example by using a non-reusable job identity, keying attempts by immutable creation/claim identity, or removing entries when jobs disappear or become eligible for another attempt.

## Fix Focus Areas
- apps/web/src/components/editor/use-render-worker.ts[14-97]
- apps/agent/src/project.ts[181-185]
- apps/agent/src/project.ts[523-529]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +39 to +47
const queueExport = useCallback(() => {
fireAndForget(
agentJson("/exports", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ format: "mp4", resolution: "1080p" }),
}),
)
}, [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

8. Export button allows duplicate queued renders 🐞 Bug ≡ Correctness

TopBar's Export button is disabled only while the local tab's render state is non-null; it does
not check whether an export for this project is already pending or rendering on the agent, and
requestExport never deduplicates against an existing unfinished job. Repeated clicks, or a click
from a second tab while a render is already queued/in-progress, create multiple duplicate export
jobs each consuming a full render.
Agent Prompt
## Issue description
Clicking Export repeatedly, or from multiple tabs, can queue multiple duplicate render jobs because neither the UI nor requestExport checks for an existing pending/rendering export before creating a new one.

## Issue Context
canExport/queueExport in apps/web/src/routes/index.tsx and the Export button's disabled logic in apps/web/src/components/editor/top-bar.tsx only consider the local tab's render state, not project.exports' pending/rendering entries; apps/agent/src/project.ts's requestExport has no dedup check.

## Fix Focus Areas
- apps/web/src/routes/index.tsx[39-47]
- apps/agent/src/project.ts[514-539]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-code-review

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit cf11acc

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant