diff --git a/README.md b/README.md index 7defb9b..5ece898 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Cutmark logo -Cutmark is a browser-based video editor for trimming clips and polishing them with optional scene detection and captions. Set precise in and out points, review the result, and export an MP4. +Cutmark is a local browser-based video editor for precise trims, joined ranges, named clips and small multi-source assemblies. Correct captions, reframe footage, adjust audio, add titles/logos, and export a reviewed MP4. **Try it live:** [cutmark.dev](https://cutmark.dev) @@ -12,10 +12,25 @@ Cutmark is a browser-based video editor for trimming clips and polishing them wi - Import MP4, MOV, WebM, and MKV videos. - Set an exact trim range by dragging the timeline or entering timecodes. -- Preview the selected range and jump to detected scene changes. -- Export clips as MP4. -- Optionally detect scene changes and generate editable captions. -- Reopen recent projects saved in the current browser. +- Review decoded source thumbnails and audio peaks, zoom the timeline, and navigate long footage. +- Preview and loop a selection, review cut context, step decoded frames, and change preview speed/volume. +- Keep, split, remove and reorder source sections with exact video/sample timing. +- Export named clips sequentially with independent captured formats and safe cancellation. +- Assemble up to four local sources, including mixed dimensions/frame rates and missing-media relinking. +- Reframe landscape, square and portrait versions with fit/fill, drag/scale, rotation/flips and preview-only guides. +- Adjust exported gain/mute/fades separately from listening volume; review measured pause suggestions. +- Add timed titles/lower thirds and transparent local image/logo layers. +- Capture source/composed PNGs and download a timecoded edit report. +- Scan source or selected footage with recorded coverage, sensitivity and frame refinement; confirm, move or dismiss scene candidates safely. +- Add searchable named source markers with color and notes, independent of scene detection. +- Independently transcribe English captions for the selection or full source. +- Correct, search, split, merge, shift and replace captions with undo and readability guidance. +- Import/export reviewed SRT/VTT, style captions, and choose actual burned-in MP4 captions or subtitle files. +- Save portable project files/source-inclusive bundles and reusable output/naming presets. +- Autosave unfinished projects and reopen them in the current browser. +- Undo and redo range, caption, marker, framing, audio, title/image and assembly decisions. +- Inspect actual source frames, enter hour timecodes, and cancel processing safely. +- Keep previous exports downloadable while revising or retrying. - Keep video processing on the device; source clips are not uploaded to an application server. ## Technologies @@ -31,7 +46,7 @@ Cutmark is a browser-based video editor for trimming clips and polishing them wi I built Cutmark as a static Next.js app so the editor can run without a video-processing backend. The browser uses WebCodecs when available and falls back to FFmpeg WebAssembly for trimming and encoding. Scene detection and speech transcription are optional steps, so the basic export workflow can stay lightweight. -Recent projects are stored in IndexedDB in the browser. New projects keep the source clip and edit settings so they can be reopened and edited later. The source clip stays on the user's device; the English speech model is downloaded from Hugging Face the first time captions are generated. Repeated word sequences are limited to prevent speech recognition loops from filling the transcript. +Unfinished drafts and exports are stored separately in IndexedDB in the browser. Projects keep source media, editable captions, scene cuts, selections, and the last successful output. Projects remain until explicitly deleted; browser storage is not a backup. The source clip stays on the user's device; the English speech model is downloaded from Hugging Face the first time captions are generated. Caption corrections remain in source time across trims and exports. Recognition text is preserved for review rather than automatically deleting repeated words. For deployment, the site is exported as static files and served from a private S3 bucket through CloudFront. GitHub Actions builds the frontend and deploys it when changes reach `main`. @@ -62,3 +77,24 @@ Caption generation downloads the Whisper Tiny English model on first use. It tra ## License MIT. See [LICENSE](LICENSE). + + +## Implementation verification and backlog + +See the [Phase 1 safety checklist](docs/phase-1.md), [Phase 2 visual-editing checklist](docs/phase-2.md), [Phase 3 caption-delivery checklist](docs/phase-3.md), [Phase 3 export-settings checklist](docs/phase-3-export.md), the [Phase 3 recovery/storage checklist](docs/phase-3-recovery.md), [Phase 4 repurposing checklist](docs/phase-4.md), [Phase 5 workflow checklist](docs/phase-5.md), and [complete item-status matrix](docs/backlog.md) for implemented, verified and deferred work. + +Frontend checks: `npm test`, `npm run lint`, and `npm run build`. Builds and development use Next.js Webpack because the default bundler emitted the processing workers as TypeScript assets rather than executable JavaScript. + +The browser suite at `frontend/tests/browser.mjs` generates local fixtures with FFmpeg and exercises the production export in Edge. It requires Playwright, an installed Edge browser, FFmpeg/ffprobe on PATH, and a local static server. Set `CUTMARK_TEST_URL` to that server, `CUTMARK_PLAYWRIGHT_PATH` to an installed Playwright package if it is not available normally, and optionally `CUTMARK_ARTIFACTS` to a folder for fixtures, outputs, and screenshots; then run `node tests/browser.mjs` from `frontend`. + +The Phase 2 suite at frontend/tests/phase2.mjs exercises decoded thumbnails/waveforms, native transport, source-based transcript operations and adaptive layout. It uses the Phase 1 fixture directory via CUTMARK_ARTIFACTS. The real-English speech suite also verifies cached recognition and full-source transcription before export. + +The scene-tools suite at frontend/tests/scenes.mjs verifies recorded/refined scene analysis, preserved cut corrections, marker notes, interrupted scans and a long source-timed transcript follow/browse workflow. It reuses CUTMARK_ARTIFACTS from the other browser suites. + +The caption-delivery suite at frontend/tests/caption-delivery.mjs verifies reviewed SRT/VTT import, persistent styles, actual burned-in MP4 captions, native/compatible paths, portrait wrapping and recovery after cancellation/font/overflow failures. It reuses CUTMARK_ARTIFACTS and requires FFmpeg/ffprobe. Export presets and actual resolution/quality/audio controls are now implemented; portable project backups/relinking, project titles/duplication and targeted cache management are now implemented in Phase 3C. + +The export-settings suite at frontend/tests/export-settings.mjs measures native/compatible quality and file-size changes, output dimensions/audio, target-sized caption parity, preset undo/reopening and persisted measured output metadata. It uses the same test environment and writes artifacts under export-settings. + +The project-backups and storage-tools suites verify fresh-profile portable recovery, exact-media relinking, shared-media duplication/deletion, version-2 migration, quota rollback, protected downloads and scoped model-cache cleanup. See docs/phase-3-recovery.md for the portable format and completed Phase 3 scope. The Phase 4/5 flows are now implemented; the item-status matrix separates completed core flows from conditional extensions and manual verification gaps. + +The new suites are frontend/tests/sequences.mjs, assembly.mjs, framing.mjs, finishing.mjs, images.mjs and join-precision.mjs. They verify actual joins, continuous audio, framing/layer pixels, per-job recovery, multi-asset backups and 100 off-grid VFR ranges without accumulated drift. Build once before starting browser tests; replacing frontend/out while pages load hashed chunks invalidates a run. Exact composed/joined exports require a supported browser video encoder; compatible preparation adapts source codecs locally. See the current checklists for limits rather than assuming all 100 conditional backlog ideas or all browsers are supported. diff --git a/docs/backlog.md b/docs/backlog.md new file mode 100644 index 0000000..ee2f358 --- /dev/null +++ b/docs/backlog.md @@ -0,0 +1,52 @@ +# Cutmark implementation checklist + +The five recommended phases have working core user flows in the local build. This is a product backlog: completion of those phases does not mean every conditional/P3 extension or every device has been implemented or verified. The distinctions below are intentional; deferred controls are not exposed as working features. + +## Phase status + +| Phase | Delivered scope | Status | +| --- | --- | --- | +| 1 — Trust/revision safety | Independent drafts/artifacts, preserved wording, named undo, consistent timing, actual frame inspection, validation, cancellation, autosave/quota safety and legacy recovery | Implemented; regression coverage retained | +| 2 — Everyday usability | Real thumbnails/waveform, zoom/navigation, transport/shortcuts/context, transcription and transcript tools, scenes/notes, adaptive and keyboard-accessible workspace | Implemented; regression coverage retained | +| 3 — Deliverable quality | Shared caption burn-in/style, SRT/VTT import/export, real output settings/validation, names/duplicates/backups/relinking and scoped storage tools | Implemented; regression coverage retained | +| 4 — Repurposing/richer edits | Exact ordered ranges, split/remove, named clip extraction, static reframing/aspect versions, gain/mute/fades/meters, reviewed pauses, titles/logos and frame capture | Implemented; see [Phase 4](phase-4.md) | +| 5 — Advanced workflow | Small multi-source assembly, linked/independent source corrections, transcript proposals, clip queue/presets/naming and local review/handoff artifacts | Implemented selected P3 flows; see [Phase 5](phase-5.md) | + +## Item coverage + +| Items | Status and limits | +| --- | --- | +| 1–10 | Implemented: revision safety, timing, named/coalesced undo, previous downloads, strict boundaries and decoded frame navigation. | +| 11–20 | Implemented core timeline/transport/shortcuts/context/synchronization. J/K/L shuttle is conditional and not exposed. | +| 21–29 | Implemented transcription/editor operations/readability and caption/subtitle delivery. Native accessibility preview is an explicitly separate source view. | +| 30 | Deferred: animated word emphasis requires trustworthy editable word timings. Segment timestamps are not presented as word timing. | +| 31–32 | Implemented supported English/local-download/cache workflow. Additional languages/larger models await measured compatibility/performance; none are advertised. | +| 33–40 | Implemented repetition preservation, no-audio/silence/cancel/retry, recorded scene workflow, candidate correction and editorial notes. Scene scans remain reviewable estimates, not guaranteed editorial truth. | +| 41–44 | Implemented kept ranges, split/remove/reorder, named clips and explicit source/sequence mappings. Ripple closes gaps; no gap-retaining mode. | +| 45 | Hard cuts implemented. Optional dissolves/crossfades deferred. | +| 46–49 | Implemented ratios, fit/fill/solid background, drag/scale/keyboard/numeric placement/reset and non-exported adjustable guides. Blurred backgrounds deferred. | +| 50 | Deferred: crop keyframes and automatic tracking. Static reframing is complete. | +| 51–54 | Implemented listening/export distinction, gain/mute/fades, sample-meter/peak feedback and reviewable padded pause removal. No standards-compliant loudness/true-peak claim. | +| 55 | Deferred: optional local music and ducking. | +| 56–58 | Implemented timed titles/lower thirds, real transparent raster overlays, orientation/flip/reset/undo and asset recovery. | +| 59 | Deferred: grading/white balance pending separately verified color/performance behavior. Unverified HDR/wide-color re-encoding is blocked. | +| 60–65 | Implemented source/composed PNGs, output-oriented rendering, useful quality/resolution/audio/framing settings, presets and actual result metadata. Encoding intent is shared, not a promise of identical codec output bytes. | +| 66–70 | Implemented cancellation/stale guards/stage progress, relevant failure/retry messages and actual output checks. No invented ETA or success from a bare worker-complete message. | +| 71–80 | Implemented drafts, names/duplicates, portable references/bundles, exact-source relinking, versioned legacy recovery, explicit retention, shared assets, quota safety and requested durable storage. Browser retention remains a policy decision, not a backup guarantee. | +| 81–84 | Implemented media/capability checks, special-source guards and bounded native processing/resource cleanup. FFmpeg preparation and final in-memory MP4s still have real device limits. | +| 85 | Deferred conditional proxy/resource modes; no fake “light preview” switch. Rendered previews explicitly use export settings. | +| 86–95 | Implemented adaptive/progressive workspace, relevant guidance, keyboard/text alternatives, coarse-pointer sizing and reduced motion. Real screen-reader/touch-hardware and broader-device audits remain manual. | +| 96 | Implemented up to four sources, explicit IDs/joins, common framing, source captions and missing-video/image recovery. Verified representative mixed dimensions/rates; not a universal codec/device guarantee. | +| 97–98 | Implemented review-before-apply transcript passage edits and measured pause/peak suggestions. Automated filler/black-frame analysis remains optional future work. | +| 99 | Implemented sequential primary-source clip jobs, individual cancellation/failure recovery and local reusable style/output/naming presets. Not a crash-resumable background publishing queue. | +| 100 | Implemented local source/composed images and actual timecoded JSON edit/marker report. Professional interchange formats await target-app verification. | + +## Verification record + +The current test inventory is 50 unit tests and fourteen production-browser scripts. Tests inspect actual media frames, timestamps, dimensions, tracks, audio gain/fades, text/logo pixels, saved state, cancellations, quota rollback and recovery. The 100-range VFR case detects cumulative join drift rather than relying on approximate total duration alone. + +Run npm test, npm run lint and npm run build in frontend. Serve the completed frontend/out directory, then run the browser scripts listed in Phase 5 using CUTMARK_TEST_URL, CUTMARK_PLAYWRIGHT_PATH and CUTMARK_ARTIFACTS. The speech suite also uses CUTMARK_SPEECH_FIXTURE. Do not rebuild the static directory while tests are loading hashed browser chunks. + +Available target: Windows Edge. Firefox/Safari, actual phone rotation metadata/touch hardware, screen readers, other-script font fallback, multi-gigabyte/high-resolution sources and sustained heap/GPU profiling remain explicitly unverified. No universal compatibility, unlimited-file-size or error-free guarantee is made. + +Final local run: **50 unit tests and all 14 browser suites passed**, with lint, TypeScript and production build successful. See the [dated verification record](verification-2026-10-14.md) for scope and limits. diff --git a/docs/phase-1.md b/docs/phase-1.md new file mode 100644 index 0000000..d5b1f76 --- /dev/null +++ b/docs/phase-1.md @@ -0,0 +1,103 @@ +# Phase 1 — trust and revision safety + +Historical phase checkpoint: the current implementation and verification inventory are tracked in [the complete checklist](backlog.md), [Phase 4](phase-4.md) and [Phase 5](phase-5.md). Portable versions 1–3 and database version 7 supersede the earlier format/storage versions described below. + +Implemented locally on main, October 14, 2026. The active checkout was relocated to Desktop/School Notes/Self made Projects/splice; the older workspace path no longer exists. The unrelated pre-existing untracked binary_ninja_lockdownbrowser_dll_functions.md was preserved. No hosting or infrastructure changes are included. + +## Editor-visible result + +- Export keeps the source, selection, editable captions and scene cuts available for revisions. +- A prior successful MP4 stays downloadable during an export, cancellation, failure or storage error. Each artifact captures its own revision, range, subtitle snapshot and output settings. +- Drafts autosave before export. Source and output assets are separate from project metadata. Saving no longer evicts projects beyond five. +- Caption text is controlled and saved as edit decisions, including intentionally blank text. Exported subtitles are derived separately; trimming never rewrites source cues. +- Named undo/redo covers trim commits, caption typing/timing/add/remove, scene cut edits and analysis settings. Typing coalesces within 800 ms; a drag commits once. History is bounded to 100 actions and is session-local. +- Shared timecodes accept seconds, MM:SS and HH:MM:SS with decimal seconds; invalid fields explain the error without changing the project. Escape discards the draft. +- Frame inspection uses decoded presentation timestamps, including fractional and variable rates. Unsupported decoders fall back to a clearly labeled approximate step. +- Export, scene scans, model setup and transcription can be cancelled. Stale jobs cannot replace the current project or output. +- English transcription is an independent action. Regeneration requires an explicit replacement choice; old corrections remain until success and can be restored with undo. +- Storage failures retain live decisions and downloads. Retry saving, source download and targeted deletion of other saved projects are accessible without leaving the editor. +- HDR/wide-color metadata is detected. Unverified trimming/re-encoding is blocked; source download remains available. + +## Checklist + +“Verified” below describes the available Windows Edge test environment, not every browser/device. + +| Backlog items | Implementation | Verification / limits | +| --- | --- | --- | +| 1–2: project/output separation and caption preservation | Implemented | Export, caption correction, trim, undo/redo, reload, regeneration/undo and repeated export passed. Blank edits and Unicode/multiline cues survive reopening. Existing caption style controls were absent; styling belongs to Phase 3. | +| 3: explicit timing | Implemented | Source cues/markers remain source-relative. Shared source/sequence conversion and cue clipping tested. Export boundaries tested with real decoded first/last frames. Multi-range joins are not exposed yet. | +| 4: undo/redo | Implemented for existing controls | Caption coalescing, trim drag commit, marker changes and keyboard undo verified. Crop/audio/range-list controls do not exist yet. Undo stacks are not persisted across reloads. | +| 5: revision-aware outputs | Implemented | Earlier output labeling, immutable subtitle/settings snapshots and retention through failure/cancellation passed. | +| 6–7: timecodes and boundaries | Implemented | Invalid/extra components, hours, rounding carry, finite bounds and short media passed. CFR selections require one measured source frame or the whole source if shorter. VFR uses representable timestamp precision instead of a fictitious fixed frame rate. | +| 8–9: frame stepping and out convention | Implemented | Decoded timestamp stepping/out inspection passed for fractional, VFR, portrait and hour-long fixtures. Approximate decoder fallback is explicitly labeled. | +| 10: snapping | Implemented | Threshold is ten rendered pixels mapped to source time. Shift bypasses snapping; text entry is independent. Code inspected; a dedicated automated snap-target test remains a verification gap. Timeline zoom is Phase 2. | +| 61: export wording | Implemented | Output name, selection duration, dimensions, audio and separate subtitle delivery are shown. Processing completion no longer implies a video exists after caption-only work. | +| 66–68: cancellation, stale jobs, progress | Implemented for existing long operations | Export/scan/transcription cancellation and retained edits passed. Encoding reports measured progress; setup/transcription/validation remain indeterminate with stage/elapsed time. No ETA is fabricated. Waveform creation does not exist yet. | +| 69–70: failure and valid output | Implemented | FFmpeg exit codes, empty output, tracks, dimensions, duration and browser decodability are checked. Corrupt worker completion is rejected; retry and prior file retention passed. Failure messages retain useful detail and recovery actions. | +| 71: autosave | Implemented | Draft before export, revisions, reload, real IndexedDB put quota failure and saving retry passed. Changes waiting to save, saving, saved and failed states are distinct. Browser storage remains best-effort. | +| 75: legacy records | Implemented | Transactional IndexedDB v1→v2 migration passed with seven legacy records. Source-relative conversion and export-only limitations are verified. Creation dates and retained media are preserved. | +| 76, 78–79: critical storage | Implemented | No automatic eviction; metadata-only listing; asset reads on reopen; unchanged media not rewritten per keystroke; atomic saving; quota safety; deletion of another project while keeping the active project passed. | +| 77: storage information/cleanup | Partially implemented | Site usage/quota estimate and per-project deletion are available. Per-asset categories and model/cache-only cleanup are deferred to Phase 3. | +| 81–84: critical compatibility/resources | Implemented foundations | Actual container/tracks/duration/dimensions are probed. Conversion verifies utilized tracks and falls back to FFmpeg. Decoded frames, input readers, workers, object URLs, scene listeners/timers and temporary files have cleanup paths. Detailed memory profiling remains unverified. | + +No implementation item above is blocked. Verification on unavailable browsers/devices remains open. + +## Timing and persistence contract + +All timestamps use seconds. Source selections are start-inclusive and end-exclusive: [in, out). Source timestamps are never rewritten by a trim. + +For a single range, output/sequence time is source time minus the in point. A cue intersects the range first; its intersected boundaries then map to output time. Subtitle delivery uses millisecond timestamps; intersections that collapse at that precision are omitted with a visible explanation, while the source cues remain saved. Cues entirely outside the selection are omitted from its subtitle deliverable, while the editable source cue remains untouched. An ordered-range mapping helper supports future joins, but the UI and renderer currently export one source range. + +The out inspector retrieves the decoded frame immediately before the excluded boundary. Seeking/transport UI smoothness is not used as a substitute for decoded frame timestamps. + +Edit decisions, playback position, source metadata and export artifacts are distinct. Every successful output retains its producing revision, range, caption snapshot and settings. Undo/redo always increments the current revision, so an older artifact remains identifiable even if decisions return to an earlier shape. + +IndexedDB version 2 has metadata and asset stores. Migration transfers v1 records in one upgrade transaction before dropping the old store; an aborted upgrade rolls back. Legacy output-relative cues and markers are normalized only when original media is available. An export-only project uses that saved export as its editable source and explicitly states the original is unavailable. + +Autosave is debounced by 600 ms, queued, and also attempted when the tab becomes hidden or the workspace exits. Switching to the project list awaits saving; a failed save keeps the editor open. A newer stored revision cannot be overwritten by an older queued save. This does not provide full conflict resolution for editing the same project concurrently in multiple tabs. + +## Completed verification + +- npm test: nine regression tests. +- npm run lint: passes without errors. +- npm run build: production static export and TypeScript checks pass. Next.js still reports the existing multiple-lockfile workspace-root warning. +- Edge production browser suite: real fixtures and exported files, not simulated captions or waveform data. +- Both native WebCodecs and single-thread FFmpeg were exercised. The native test explicitly blocked FFmpeg assets. +- Known color boundaries: exported first frame is green and last frame is blue, excluding the yellow frame at the out point. +- Fractional 30000/1001, actual VFR, portrait, single-frame and synthetic hour-long sources passed inspection and playable output checks. +- Silent audio, absent audio tracks, malformed timecodes, controlled caption typing, undo/redo, reload, cancellation, corruption, quota failure/retry and legacy migration passed. +- Desktop 1366×900 and mobile 390×844 layouts were captured; no horizontal overflow or uncaught browser errors were observed. +- Real English transcription was verified using a locally synthesized speech recording. The first model download produced the expected sentence before a video export. A fresh transcription worker reused cached model assets while Hugging Face network access was blocked; undo restored the corrected name. +- Model-download failure and cancellation keep the previous edited transcript. +- HDR verification uses a synthetic metadata-tagged fixture to check the guard, not a claim of HDR rendering support. + +The worker build uses Next.js Webpack. Inspection and browser tests found that the default bundler emitted workers as raw TypeScript assets; those workers could not execute. Tailwind configuration also now resolves relative to its own file so launching from a different directory cannot silently remove the styles. + +## Deferred work + +The core Phase 2 editing workflows are now implemented; see [Phase 2](phase-2.md) for current verification and the remaining analysis, language and accessibility backlog. The table below records the original phase allocation. + +| Phase | Remaining work | +| --- | --- | +| Phase 2: 11–20, 22–25, 31–40, 86–95 | Thumbnail/waveform generation, timeline zoom/navigation, scrubbing improvements, loop/speed/contextual review, richer shortcuts, cue highlighting/search/split/merge/group shift, readability warnings, accurate cache status/download-size UI, scene analysis records/refinement/sensitivity, named editorial markers, adaptive workspace and broader accessibility/touch work. Native playback, frame controls, focus-safe shortcuts, controlled captions and reduced motion already have foundations. | +| Phase 3: 26–30, 62–65, 72–74, 77, 80 | Actual caption burn-in and matching style/layout, subtitle import, export quality/presets, project rename/duplication, portable backups/relinking, detailed storage/cache cleanup and durable-storage requests. Current video exports deliver captions as sidecars only. | +| Phase 4: 41–60 | Multiple kept ranges, ripple/split edits, extraction/batch workflows, reframing/aspect ratios, audio adjustments and shared preview/render effects. | +| Phase 5: 96–100 and advanced timing | Multiple sources, transcript-driven edits, review suggestions, persistent queues/presets and handoff artifacts. Word emphasis remains deferred until reliable word timing exists. | + +## Remaining verification and manual checklist + +1. On Firefox, Safari and mobile browsers, import, trim, frame-step, export, cancel, retry and reopen a draft. Record actual codec limits and fallback behavior; do not infer support from API existence. +2. Check frame-snapping target feedback at different rendered widths and confirm Shift bypass and typed values. +3. Exercise an actual phone rotation-metadata file, a real long high-resolution recording, HDR/10-bit footage and memory pressure. Portrait geometry and hour timestamps were tested, but these are not substitutes for those characteristics. +4. Verify multi-thread FFmpeg under the deployed cross-origin-isolated hosting headers; the available tests used the compatible single-thread path. +5. Use a screen reader, touch input and increased text scaling for import, transport, trim fields, caption correction, dialogs and saving errors. +6. Profile repeated successful imports/exports/cancels/reopenings for retained resources. Cleanup paths and absence of uncaught errors were inspected/tested; no heap plateau claim is made. +7. Test real near-quota storage and upgrade interruption on browser/device implementations. A real IndexedDB put failure was injected; physically filling the browser quota was not performed. +8. Verify unusual codecs/containers, audio/video timestamp offsets, damaged files and full end-to-end playback on additional devices. +9. Before later phases ship, add SRT/VTT import round trips, preview/burn-in comparison, backup/relinking, actual multi-range joins and rendering parity tests. Those flows are currently deferred. + +## Running the browser checks + +Install or provide Playwright and Edge, and put FFmpeg/ffprobe on PATH. Serve frontend/out with a local static server, then run frontend/tests/browser.mjs with CUTMARK_TEST_URL pointing to it. CUTMARK_PLAYWRIGHT_PATH can point to an installed Playwright package; CUTMARK_ARTIFACTS selects the fixture/output/screenshot directory. + +The optional tests/speech.mjs check additionally needs CUTMARK_SPEECH_FIXTURE pointing to a local English-speech MP4 and network access for the first public model download. The verification fixture was generated locally with Windows speech synthesis; no user media or transcript was uploaded. diff --git a/docs/phase-2.md b/docs/phase-2.md new file mode 100644 index 0000000..cbdea65 --- /dev/null +++ b/docs/phase-2.md @@ -0,0 +1,62 @@ +# Phase 2 — visual editing and transcript workflow + +Historical phase checkpoint: the current implementation and verification inventory are tracked in [the complete checklist](backlog.md), [Phase 4](phase-4.md) and [Phase 5](phase-5.md). Portable versions 1–3 and database version 7 supersede the earlier format/storage versions described below. + +Implemented locally on October 14, 2026. This delivers the core daily-editing portion of the Phase 2 backlog. It preserves the source-based project and validated, revision-aware export contracts from Phase 1. Scene analysis/review and editorial markers are now implemented as a second Phase 2 increment. Remaining language, device verification and workspace customization work is explicitly deferred below. + +## Delivered and verified + +“Verified” means the production static build in Windows Edge, with real decoded media and native playback. It does not imply validation on every browser or device. + +| Backlog items | Status | Evidence and limits | +| --- | --- | --- | +| 10: predictable snapping | Implemented, verified | Ten visible pixels map to the current viewport span. A highlighted target and boundary label appear during drag. Browser checks cover target feedback, Shift bypass, typed values, and one undo entry per drag. Unit checks cover different zoom spans and long-source contexts. | +| 11: footage thumbnails | Implemented, verified | A worker decodes actual source frames at visible slot times, prioritizing the center and active boundary. Frames arrive progressively with their decoded timestamps. Aspect ratio is preserved, 64 cached images are bounded and evicted URLs are revoked. Real green/blue fixture frames match their source timestamps. | +| 12: waveform | Implemented, verified | Streamed decoded audio becomes bounded source-aligned peak bins and a multiresolution pyramid. The rendering query uses only blocks contained in the requested pixel interval, preventing nearby transients from filling pauses. Real AAC peaks and rendered pixels distinguish known signal/silence on a ten-minute fixture. Silent audio and absent tracks have separate messages. | +| 13–15: timeline interaction | Implemented, verified | Zoom around the playhead, fit source/selection, horizontal viewport navigation, source overview, optional playback follow, accessible draggable playhead, generous trim targets and boundary thumbnails. Rapid drags coalesce native seeks; the final media time is checked. Fit/navigation work on ten-minute media. Full-source navigation hides when it is unnecessary. | +| 16–17: transport/context | Implemented, verified for normal playback | Play/pause, decoded frame steps, included out-frame inspection, selection loop, preview speed/volume and configurable before/after cut review with replay. Context is explicitly labeled as including footage outside output. Native media rate/volume and multiple selection loops are checked. Fullscreen/PiP use native browser APIs; real window modes remain manual verification. | +| 18–20: keyboard and frame synchronization | Implemented foundations, regression verified | Existing Space, I/O, frame arrows, larger-step modifier and named undo/redo remain scoped to the workspace. Typing, composition, dialogs and browser modifier combinations are respected. Native frame callbacks update the playhead where available. Predictable reverse J/K/L shuttle is deferred. | +| 21–24: transcription and transcript editing | Implemented, verified | Transcribe the selection or full source before export. Source-timestamp cues highlight during playback; cue/search actions seek to the source. Controlled text/timing, add at playhead, split timing at playhead and wording at the actual caret, adjacent merge, delete, signed group shift and literal replace preview are reversible. Actual Unicode/multiline edits, operations, save/reopen and selected SRT timing pass. Split does not claim word-level timing. | +| 22: cue following | Implemented, verified | Optional following scrolls only the transcript container. A 180-cue source-timed transcript on actual ten-minute media verifies active highlighting, container-only scrolling, manual wheel browsing pausing follow, Resume, and search seeking. Caption text was seeded as manual fixture annotations; this is not a claim of recognizing ten minutes of speech. Real touch and screen-reader checks remain open. | +| 25: timing/readability | Implemented, verified | Invalid millisecond intervals, selection intersections, overlap, line length/line count and reading speed are flagged. Guidance is configurable and saved. Notes can acknowledge intentional guidance exceptions, while invalid intervals cannot be acknowledged. Exceptions are tied to reviewed timing/text/range/guidance, so a changed context requires review again. Wording is preserved. Delivery actions show the flag count. | +| 31: first model run | Partly implemented, verified | Purpose, local processing and English coverage are explained before starting. The UI inspects actual browser-cache requests to distinguish missing/partial/cached model weights. Download asset progress and transcription stages are distinct. First download, cached network-blocked retries and cache messaging pass. A reliable approximate total download-size display is deferred; no total is invented. | +| 33–35: recognition outcomes | Implemented, verified | Existing deliberate repetition preservation and independent cancellation/retry remain. Silence/no speech are normal statuses that preserve corrected cues, distinct from technical failure. Absent audio disables transcription. | +| 36–39: scene workflow | Implemented, verified | One worker-backed detector serves the editor and compatibility entry points. Source/selection scope, quick/refined mode, fewer/balanced/more sensitivity and recorded coverage/settings are saved. Refined hard-cut timestamps match real transition frames with before/at thumbnails. Confirm, move, dismiss, add and select-scene operations are undoable. Rescans preserve manual/confirmed/dismissed corrections by default; explicit replacement is scoped and confirmed. Partial scans preserve unchecked cuts. Cancellation, failed worker setup, retry and reopening pass. Dissolve, motion and flash footage is tested; refinement searches candidates, not every source frame, and can still miss cuts or flag non-cuts. | +| 40: editorial markers | Implemented, verified | Source-time names, optional color/notes, name/note search, time editing, timeline/keyboard-accessible seeking and removal with undo. Notes and colors survive rescans and reopening. These annotations are separate from detected cuts and do not become burned-in video text or exported chapters. | +| 66, 82–84: analysis lifecycle | Implemented foundations, verified | Waveform and thumbnail work run separately from export/transcription. Their availability never blocks basic editing. Cancelling waveform terminates its worker; stale setup cannot update the UI. Independent retry and source replacement pass. Media readers, frames, workers and URLs have cleanup paths; heap profiling remains open. | +| 86–89: adaptive workspace | Implemented core, verified | A compact source preview with transport/timeline and a desktop caption/details inspector. Mobile Trim/Captions/Details buttons expose relevant panels while preserving drafts. Secondary scene/export/storage controls are disclosures; export actions stay near the work area. Desktop/mobile screenshots, 390px viewport, larger text and reduced motion checks pass without horizontal overflow. Persistent panel dimensions and broader customization are deferred. | +| 90–95: accessibility/touch/privacy | Partly implemented | Native labeled inputs/buttons, keyboard sliders, focus styles, normal text entry, reduced motion and a narrower-screen tool switcher are available. Decode/model limitations and local-media behavior are explicit. Real touch manipulation and a full screen-reader/contrast audit remain manual work; language promises remain English only. | + +No core implementation item above is blocked. Advanced work is deferred by phase; unavailable device/browser coverage is a verification gap. + +## Technical contracts + +- Timeline, waveform, caption editor and source transport use source seconds. Trim is start-inclusive and end-exclusive. Selected subtitles map through the shared source-to-output range conversion. +- Thumbnails use decoded presentation timestamps, not labels fabricated from slot centers. Frame stepping/out inspection still uses decoded frame timestamps. +- Waveform peaks are amplitude envelopes, normalized to the source maximum for visual readability; they are not calibrated loudness meters. Base bins are approximately 5 ms until the 262,144-bin cap requires coarser aggregation on longer media. Packet memory is bounded independently. +- Timeline seeks retain the latest request while the native element is already seeking. Switching media cancels pending seeks and analysis. Preview speed/volume/context/follow controls do not alter export decisions. +- Caption edits, review notes and readability guidance are saved in project metadata. Existing projects without guidance use sensible defaults. Undo history remains session-local. An operation is one named edit; text typing remains coalesced. +- Scene scans sample at most 480 positions, including the last included source frame. The record identifies source, requested area, settings, detector version, time, planned/checked samples, spacing, actual partial coverage and refinement count. Quick/refined time budgets are 30/120 seconds, with a 150-second worker-response limit. Completing sampling does not imply every intervening frame was checked; refinement completion/limits remain separately visible. +- Unreviewed scene candidates are refreshed only in the checked area. Manual/confirmed cuts and dismissed brackets survive ordinary rescans, while outside cuts always remain. Explicit replacement clears reviewed cuts and wholly covered dismissals in the checked area. Moved detected cuts retain a dismissal of their original bracket. These decisions are named undo actions and stored without rewriting media assets. +- Editorial markers are independent source annotations. Their text, colors and positions persist through trim/export/rescans, and never alter output pixels. +- At the completion of this phase, caption delivery remained separate SRT/VTT files. Styling and actual burn-in are now implemented in the [Phase 3 caption-delivery step](phase-3.md). + +## Verification + +- Twenty unit regressions pass, covering timing, mappings, revisions, migration, zoom, pixel snapping, waveform intervals, caption operations and reviewed exceptions. +- Lint, TypeScript and production static build pass. The existing multiple-lockfile workspace-root warning remains. +- The Phase 1 browser suite passes: actual native/FFmpeg exports, first/last boundary frames, fractional/VFR/portrait/single-frame/hour-long fixtures, previous output retention, cancellation, corruption/retry, quota safety, seven-record migration, cleanup and HDR guard. +- The Phase 2 browser suite passes: real decoded thumbnails, latest seek, snap target/bypass, native loop/rate/volume, contextual replay, transcript operations, persisted guidance/review notes, subtitle alignment, mobile/larger text/reduced motion, ten-minute AAC pause alignment, waveform cancellation/retry and no uncaught browser errors. +- The scene-tools suite passes: decoded quick/refined boundary comparisons, sensitivity/scope records, cut confirmation/movement/dismissal, explicit scoped replacement, partial coverage, cancel/failure/retry, scene selection, searchable source markers with Unicode notes/colors, reopening, mixed dissolve/motion/flash footage and the 180-cue follow/browse workflow. The partial-scan test advances the actual worker clock after decoded samples to exercise its real time-limit branch. +- The real speech suite passes: initial model download, transcription before encode, cached retry with model network blocked, undo preserving a name correction and full-source cue timestamps while a shorter trim remains selected. +- The long recording is a synthetic ten-minute timing/pause fixture, not a claim of testing every characteristic of a real high-resolution talking-head recording. + +Run npm test, npm run lint and npm run build from frontend. Serve frontend/out and run tests/browser.mjs, tests/phase2.mjs, tests/scenes.mjs and tests/speech.mjs with CUTMARK_TEST_URL and CUTMARK_PLAYWRIGHT_PATH configured. The Phase 2 suite needs CUTMARK_ARTIFACTS pointing to the Phase 1 fixture directory; it creates a phase-2 subdirectory. The speech suite also needs CUTMARK_SPEECH_FIXTURE pointing to local English speech. + +## Next backlog and manual checks + +1. Scene records, sensitivity/refinement, candidate movement/confirmation, editorial markers and the multi-cue follow/browse acceptance session are implemented and verified. Further detector accuracy/performance tuning requires representative high-resolution real recordings; the current heuristics remain reviewable candidates. +2. Verify fullscreen/PiP, touch dragging, phone rotation metadata, low-powered/high-resolution media, screen readers, text contrast and Firefox/Safari behavior. Profile repeated import/analysis/export cycles for a stable heap. +3. Add other transcription languages/quality choices only after demonstrating supported models and acceptable performance; add a measured approximate model-size explanation. +4. The [Phase 3 caption-delivery step](phase-3.md) completes subtitle import, shared preview/export styling and actual burn-in. Export presets and output controls are now verified in [Phase 3B](phase-3-export.md). [Phase 3C](phase-3-recovery.md) now completes names/duplication, portable recovery and targeted storage management. Continue with Phase 4 sequence and repurposing work. +5. Keep multi-range joins, reframing, audio/effects and later multi-source/review/queue capabilities in their later phases, with rendering parity and preservation tests. diff --git a/docs/phase-3-export.md b/docs/phase-3-export.md new file mode 100644 index 0000000..4a5b0ea --- /dev/null +++ b/docs/phase-3-export.md @@ -0,0 +1,59 @@ +# Phase 3B — export settings and inspection + +Historical phase checkpoint: the current implementation and verification inventory are tracked in [the complete checklist](backlog.md), [Phase 4](phase-4.md) and [Phase 5](phase-5.md). Portable versions 1–3 and database version 7 supersede the earlier format/storage versions described below. + +Implemented locally on October 14, 2026. Caption delivery was completed in [Phase 3A](phase-3.md). This step implements actual export resolution/quality/audio settings, useful presets and persistent measured output inspection. [Portable recovery, names/duplication and cache management are now completed in Phase 3C](phase-3-recovery.md). No hosting changes are included. + +## Editor-visible changes + +- Export settings are disclosed within Export and storage details. Match source, Smaller file and High quality presets expose their resulting settings rather than hiding them. +- Choose source dimensions or an upper 1080p/720p/480p limit. Resolution is bounded by the longest edge for landscape or portrait; footage is never enlarged, reframed or cropped. Encoding-size rounding can introduce a slim letterbox border. +- Choose Smaller file, Balanced or High quality compression. Choose whether source audio is included. Preview volume still belongs to source review, while the output summary explicitly identifies absent audio. +- Frame timing remains source-based, including variable rates. Codec/resolution/framing controls that have no implemented renderer are not offered. Vertical reframing belongs to Phase 4. SRT/VTT downloads remain available without video encoding. +- Export captures the settings and project revision. Settings and presets are saved editing decisions with named undo/redo. Existing projects receive Match source defaults. +- Before encoding, the summary names dimensions, duration, source timing, audio and caption delivery. Afterwards, measured dimensions/tracks/codec/duration/file size appear alongside the captured quality intent. Measured metadata survives reopening; legacy files without stored measurements retain their recorded settings. +- The source remains available after export. Cancellation, failed validation and quota errors preserve the prior successful MP4. +- File selection is available after the client import handler is ready, preventing an initial source-selection race. + +## Checklist + +| Backlog items | Status | Verification / limits | +| --- | --- | --- | +| 62: export settings | Implemented for current single-range capabilities | Resolution, quality, audio inclusion and caption delivery affect actual MP4 output through both processors. Original/VFR timestamp behavior remains fixed rather than offering unverified frame-rate conversion. | +| 63: output presets | Implemented for supported delivery | Match source, Smaller file and High quality set inspectable controls. Caption-only SRT/VTT downloads already work without encoding. Vertical short/framing presets remain deferred until actual reframing is implemented. | +| 64: consistent engine intent | Implemented | Shared output dimensions, audio and quality policy feed native conversion and FFmpeg. Bitrate/quantizer and CRF express matching quality intent, not identical algorithms or file sizes. Compatible-source caption preparation uses high intermediate quality, followed by the requested final policy and the shared caption renderer. | +| 65: summary and output inspection | Implemented | Actual dimensions/tracks/duration/playability must match the request before success. Measured metadata, file size, codec and prior-version settings persist. No speculative size estimates are presented. | +| 72–74 and expanded 77/80 | Completed in [Phase 3C](phase-3-recovery.md) | Names/duplication, portable backup/relinking, targeted model cleanup and explained retention requests are implemented and verified. Existing autosave, source download, individual project deletion and quota recovery remain working. | + +## Output contract + +ExportPreferences are optional version-compatible edit decisions. The resolved OutputSpec contains the requested canvas size, audio presence and encoder policies. Lower-resolution dimensions preserve aspect ratio to even-pixel rounding; both paths contain the source within those dimensions. Source dimensions are retained exactly when no downscale is necessary. + +A full-length MP4 with Match source, Balanced compression, unchanged audio and sidecar captions can reuse its original bytes. Trims, High quality/Smaller file, actual resizing, removal of an existing audio track or burned-in captions require encoding. Selecting a ceiling above a small source does not force enlargement. + +Native conversion applies target dimensions before captions. Caption preview uses the same target-sized layout and canvas; the source preview remains the original footage. The caption renderer returns an explicitly timestamped RGBA VideoFrame. Testing found that canvas-backed VideoFrames could displace glyphs by one pixel at 854px width; supplying explicit pixels eliminates that displacement without compensating caption positions. Returned frames are closed by the conversion implementation; each worker is terminated on completion/cancellation. Pixel readback adds per-frame work, so large-media performance remains an explicit verification gap. + +FFmpeg uses an aspect-preserving scale/pad when resizing, passes through original timestamps and applies the chosen H.264 CRF/AAC policy. For burned-in delivery, it prepares the selected source, then the shared browser renderer applies the final caption layout/quality; copied prepared audio avoids a second AAC encode. Omitted audio is never decoded unnecessarily by the native path and is absent from both exports. + +Validation compares playable output metadata with the resolved request, not with unchanged source dimensions/audio. HDR/wide-color re-encoding guards include every setting that requires an encode; Match source on the unchanged full range remains the preservation path. + +## Verification + +Windows Edge is the available browser target. + +- 29 unit regressions pass, including landscape/portrait resolution ceilings, no enlargement, intentional audio omission, reversible settings and immutable output snapshots. +- Lint, TypeScript and production static build pass. The existing multiple-lockfile workspace-root warning remains. +- The export-settings browser suite checks actual MP4s from native and compatible processors: High versus Smaller compression, original 24fps timing, audio retention, reduced-resolution audio omission, target-sized Unicode captions, presets/undo/reopen, measured metadata persistence, mobile overflow, and reduced portrait multiline captions. +- On the tagged BT.709 1280×720 fixture, native Smaller/High files measured approximately 0.55/1.37 MB and 38.94/53.10 dB PSNR against the same source frame. Compatible Smaller/High measured 0.79/1.48 MB and 39.79/50.28 dB. These are observations for this fixture, not promised compression ratios or a quality claim about every recording. +- Reduced landscape output is 854×480 with no audio track; reduced portrait output is 480×854. Caption comparisons use exact-position ink agreement, without accepting a coordinate shift. +- The existing export-safety suite passes: precise first/last boundaries, actual audio, retained source/corrections, cancellation, quota failure/retry, corruption/retry, migration, blank captions, fractional/VFR/portrait/single-frame/hour-long media, and guarded HDR. +- Caption-delivery regressions cover subtitle import, saved styles, font/overflow failure, cancel/retry, sidecar-only video, safe areas, native accessibility caption track, compatible captions/audio and tiny portrait frames. + +Run npm test, npm run lint and npm run build from frontend. Serve frontend/out and run tests/export-settings.mjs using CUTMARK_TEST_URL, CUTMARK_PLAYWRIGHT_PATH and CUTMARK_ARTIFACTS as described in README. FFmpeg/ffprobe and Edge are required; artifacts are written under export-settings. + +## Remaining work + +1. Phase 3 is completed by [portable recovery/storage](phase-3-recovery.md). Continue to Phase 4 multi-range sequences and repurposing. +2. Check real high-resolution/long recordings, rotation metadata, low-powered devices and pixel-readback performance. Untagged source colors can be interpreted differently by decoders; the quantitative fixture uses explicit BT.709 metadata. Broader SDR color preservation remains a manual check. +3. Verify Firefox/Safari, screen readers, touch devices and fullscreen/PiP behavior. No universal codec or browser support claim is made. +4. Keep crop/reframing, multi-range joins, richer audio controls and vertical-format presets in Phase 4. diff --git a/docs/phase-3-recovery.md b/docs/phase-3-recovery.md new file mode 100644 index 0000000..e8df5bf --- /dev/null +++ b/docs/phase-3-recovery.md @@ -0,0 +1,72 @@ +# Phase 3C — portable recovery and storage + +Historical phase checkpoint: the current implementation and verification inventory are tracked in [the complete checklist](backlog.md), [Phase 4](phase-4.md) and [Phase 5](phase-5.md). Portable versions 1–3 and database version 7 supersede the earlier format/storage versions described below. + +Implemented locally on October 14, 2026. Together with [caption delivery](phase-3.md) and [export settings](phase-3-export.md), this completes Phase 3 for the supported single-source, single-range Windows Edge workflows. No hosting, accounts or cloud processing are introduced. + +## Editor-visible result + +- Rename a project independently of its source filename. Names persist and participate in named undo/redo. Export filenames capture the title at rendering time; renaming does not relabel an earlier output. +- Duplicate the current project into an independently editable copy. Creation time is new for the duplicate and preserved through later exports. Source media stays shared, and an inherited export remains shared until replaced. +- Download a lightweight .cutmark.json project file or a .cutmark bundle including the complete source. Both capture current trim, corrected captions, style/delivery, readability guidance/review notes, scene cuts/analysis/settings/dismissals, editorial notes and export preferences. +- Restore into a separate project after reviewing its title, source and cue/marker counts. Lightweight files require matching media; bundles include it. A differently named exact source works. A different video with the same filename fails. +- Cancellation, malformed files, unsupported versions and quota failure preserve the current project and available MP4. Import transactions roll back metadata and media together; recovery can be retried. +- Inspect saved-media references, estimated site usage/quota and the English model cache. Shared media is counted once within each category; older unmeasured records and unavailable cache sizes are identified. +- Remove only the cached English Whisper model files, preserving other models/caches, projects, captions and source/output media. The next transcription downloads missing model files again. +- Request browser storage retention only after an explanation. A grant reduces automatic eviction; it does not replace downloaded backups or protect against clearing browser data. + +## Checklist + +| Backlog items | Status | Verification / limits | +| --- | --- | --- | +| 72: names and duplication | Implemented | Rename/undo, saved titles, source filename preservation, immutable output filenames, creation times and independent duplicate edits pass. Each project retains its latest successful output; duplicate creates an independently revisable variant. | +| 73: portable project format | Implemented | Versioned lightweight JSON and source-included binary bundles are downloaded and restored in fresh browser contexts. Previous rendered MP4s, session undo, models and derived preview data are deliberately excluded and explained before download/recovery. | +| 74: media relinking | Implemented | Complete-content fingerprint, byte size, duration, dimensions, codec/track presence and HDR metadata checked. Filename is advisory. Same-name changed content fails; renamed identical media passes. Converted media is not accepted as an exact replacement. | +| 75: legacy preservation | Implemented | Version-1 recent records and version-2 combined assets remain readable. Original-source versus export-only provenance stays explicit in portable files. No original source is inferred from a saved export. | +| 76: visible retention policy | Implemented | Projects remain until explicit deletion. Latest output replaces the prior stored output only after successful rendering/validation; shared duplicate outputs remain while referenced. No silent project eviction or pin exception is needed because all projects follow the same retention policy. | +| 77: information and targeted cleanup | Implemented for current caches | Site estimate, measured media references and cache file counts/reported lengths available. Scoped English-model cleanup and individual project deletion pass. Preview thumbnails/waveforms are memory-only and released on source changes. No erase-everything action is offered. | +| 78: metadata and media separation/reuse | Implemented | Listings use metadata. Duplicates share source/export references; parent deletion preserves media needed by a duplicate. A new export stores only that new output, keeping its source shared. Unreferenced asset keys are collected after export/deletion. | +| 79: quota safety | Implemented | Open work/downloads retained; portable downloads require no IndexedDB save. Failed recovery leaves no partial project and allows retry. Existing save-retry and source/output downloads remain. | +| 80: durable storage | Implemented | Explained user-triggered request with grant, denial and unsupported/error handling. Actual grant policy remains browser-controlled. Local file handles are not required; ordinary downloads and IndexedDB remain the portable fallback. | + +## Portable format v1 + +The lightweight file is UTF-8 JSON with format cutmark-project, version 1, title, creation/edit times, a source reference and current edit decisions. The source reference records original filename/type, byte size, metadata, provenance and a content fingerprint. Unknown format versions, editing features and unsupported range counts fail rather than being silently discarded. This version supports one source range; Phase 4 must extend the format when sequence editing is added. + +The bundle is a Blob concatenation, not a ZIP or base64 document: + +1. Eight ASCII bytes CMARK001. +2. Four-byte little-endian unsigned JSON-header length. +3. That many bytes of the same UTF-8 project JSON. +4. The complete source bytes. + +Metadata is limited to 16 MiB and collections to 50,000 entries, with bounded nesting and validated timing, identifiers, styles, notes and settings. A source-length mismatch, incomplete header or invalid UTF-8/JSON is rejected. A rendered output is not included as an additional artifact. For an export-only legacy project, the retained export is the source itself and its limitation is recorded. + +Fingerprint algorithm sha256-chunks-4m-v1 hashes each consecutive 4 MiB source chunk with SHA-256, turns those digests into lowercase hexadecimal, then hashes UTF-8 text cutmark-source-v1::. Every source byte participates. This is an identity check, not a signature authenticating who authored a project file. + +Hashing is cancellable between bounded reads/digests. Completed fingerprints are cached by immutable Blob identity. Bundling concatenates the source Blob directly instead of reading the entire video into a JavaScript buffer. Recovery probes the source and checks its fingerprint before committing or changing the open project. There is no mismatch override or automatic timing clamping. + +## Saved assets and compatibility + +IndexedDB database version is now 3; editable metadata schema remains 2. Upgrading preserves the existing projects/assets stores, legacy combined records and all prior media. Lower-version clients cannot reopen that database and overwrite reference fields. Version-change handlers close connections; blocked upgrades report a retry/close-other-tabs instruction. A stale tab's live data and downloads are not erased, and no automatic refresh is forced. + +New records store source media under a stable source-asset key and each rendered output under an export-artifact key. Project metadata records both references. Duplicates reuse them. Changing an export updates only its output reference. Deleting a project collects only keys no remaining project uses; source media survives deleting its original owner when a duplicate still refers to it. + +Older combined records can retain an old output alongside their source until that source asset is removed; cleanup favors preserving legacy media. Reported source/output category sizes can overlap for export-only legacy media, and the UI explains this. Site usage remains an estimate including storage overhead and browser caches. + +## Verification + +- 35 unit regressions pass, including UTF-8 portable round trips, Unicode/multiline wording, long caption review keys, markers/styles/provenance, invalid/unsupported metadata, truncated bundles, exact-source rejection, cancellable multi-chunk hashing, shared-media accounting and model URL scope. +- Lint, TypeScript and production static build pass. The existing multiple-lockfile workspace-root warning remains. +- The project-backups Edge suite passes actual project/bundle downloads; rename/undo and output-name snapshots; shared source/output duplication; parent deletion and reopening; independent export and unused-output collection; version-2 combined-asset upgrade and stale-client rejection; fresh-context exact-source relinking; restored cuts, styles, words and notes; bundled recovery and a valid new MP4; malformed version, cancellation, quota rollback/retry; mobile overflow and no uncaught browser errors. +- The storage suite uses real CacheStorage entries to verify removing only the English-model repository while preserving unrelated cache entries, project assets, edited captions and the prior MP4. Cancellation retains remaining entries. Grant/denial responses are injected to verify UI/API handling; these are not a claim that Edge always grants persistent storage. +- Earlier safety, caption-delivery, export-settings, timeline/transcript, scene/marker and real-speech suites all pass on the upgraded database. Actual cached English recognition works with model downloads blocked. +- Large-media architecture uses bounded hashing and Blob-backed bundles. Actual multi-gigabyte archive downloads/restore, storage limits, browser heap stability and file-dialog delays remain manual performance checks. + +Run npm test, npm run lint and npm run build from frontend. Serve frontend/out and run tests/project-backups.mjs and tests/storage-tools.mjs with CUTMARK_TEST_URL, CUTMARK_PLAYWRIGHT_PATH and CUTMARK_ARTIFACTS configured. Backup tests reuse Phase 1 boundary.mp4 and Phase 3 compatible-burned-audio.mp4 fixtures. Outputs/screenshots are written under project-backups and storage-tools. The full verification set has eight browser scripts: browser, phase2, scenes, speech, caption-delivery, export-settings, project-backups and storage-tools. + +## Remaining work + +Phase 4 is next: ordered multi-range sequences, removing sections, clip extraction, aspect-ratio variants/static reframing and essential audio adjustments. Extend portable versioning with those real editing decisions and verify joins, timing and output parity before exposing controls. + +Manual gaps remain Firefox/Safari, touch/screen-reader flows, other-script font fallback, real phone rotation metadata, large/high-resolution recordings and resource profiling. Word emphasis remains deferred until reliable editable word timing exists. Browser retention and quota policies vary; portable files remain the recovery path. diff --git a/docs/phase-3.md b/docs/phase-3.md new file mode 100644 index 0000000..0ad523b --- /dev/null +++ b/docs/phase-3.md @@ -0,0 +1,57 @@ +# Phase 3A — caption delivery + +Historical phase checkpoint: the current implementation and verification inventory are tracked in [the complete checklist](backlog.md), [Phase 4](phase-4.md) and [Phase 5](phase-5.md). Portable versions 1–3 and database version 7 supersede the earlier format/storage versions described below. + +Implemented locally on October 14, 2026. This completes the caption-delivery portion of Phase 3, not the entire deliverable-quality phase. [Export settings and presets are now implemented in Phase 3B](phase-3-export.md). [Portable project recovery and expanded storage management are now completed in Phase 3C](phase-3-recovery.md). No hosting or infrastructure changes are included. + +## Editor-visible result + +- Import SRT or VTT through a review step. Choose source timestamps or timestamps relative to the current selection, then append cues or replace all captions. Existing corrections remain until Apply; undo restores them. +- Choose subtitle files, burned-in MP4 captions, or both. Current-selection SRT and VTT downloads preserve words and clipped output-relative timing. +- Style captions with local Geist Sans/Mono fonts, regular/bold text, size/color, outline/shadow, a background plate, alignment, placement, and edge margin. Clean, social and high-contrast presets give useful starting points. +- The source preview and MP4 renderer share wrapping, sizing, line-height, positioning and transparent caption compositing. Text stays inside the safe area; long words wrap without dropping characters. +- Save/reopen and named undo preserve style, delivery and imported/edited words. Existing projects default to subtitle-file delivery. Successful exports retain their own style/delivery snapshot. +- Failed font loading, captions that cannot fit, cancellation and failed encoding leave the previous MP4 downloadable and the project editable. +- Native caption preview remains available through the video track. Fullscreen includes the styled canvas. Picture in picture uses the source/native caption track and explicitly explains that limitation. + +## Checklist + +“Verified” describes Windows Edge in the available environment. + +| Backlog items | Implementation | Verification / limits | +| --- | --- | --- | +| 26: burned-in caption delivery | Implemented | Actual H.264 MP4 pixels, sidecar-only plain video, half-open cue boundaries, native encoding and compatible-source preparation with audio pass. Both delivery retains separate SRT/VTT downloads. | +| 27: style editor | Implemented | Local licensed fonts, bold, size, color, outline, shadow, plate/opacity, alignment, placement and margin controls feed the shared renderer. Presets, changes, undo and reopening pass. Latin fonts are bundled; other scripts use installed fallback fonts, so availability can vary by device. | +| 28: preview/render agreement | Implemented | Landscape sans/mono, bold/outline/shadow/top-right placement, portrait multiline/Unicode/long-word wrapping, safe areas and actual exported glyphs pass. Oversize text errors rather than silently shrinking or removing wording. Codec compression is not pixel-identical to the unencoded canvas. | +| 29: subtitle import/export | Implemented | SRT/VTT parsing, source/selection bases, append/replace, rejected out-of-bounds imports, malformed-block review, encoding rejection, fresh IDs and Unicode/entity/multiline round trips pass. UTF-8 and BOM-marked UTF-16 supported; 2 MB/5,000-cue limits. | +| 30: word emphasis | Deferred | No fabricated word timings; existing segment captions remain the supported workflow. | +| 62/64/65: export settings, engine intent, summary | Implemented in [Phase 3B](phase-3-export.md) | Caption mode is explicit and affects actual MP4 pixels. Compatible-source preparation still uses the shared styled renderer; an unavailable browser encoder causes an actionable failure, never a plain-video substitute. Resolution/quality/audio controls and measured metadata persistence are implemented in Phase 3B. Existing measured output metadata remains available. | +| 72–74: names, duplication, portable backup/relinking | Completed in [Phase 3C](phase-3-recovery.md) | Existing autosave/source/output separation remains; portable project files/bundles now provide recovery beyond browser storage. | +| 77/80: cache cleanup and durable storage | Completed in [Phase 3C](phase-3-recovery.md) | Existing usage/quota, individual saved-project deletion, source download and save retry remain. Targeted English-model cleanup and explained persistent-storage requests now work. | + +## Rendering and timing contract + +Editable cues remain in source seconds. Import with selection-relative timestamps adds the selection start once; it does not change stored cues when a trim later changes. Burn-in and selected sidecars consume the same clipped output-relative cues. Cue intervals remain start-inclusive/end-exclusive. + +Fonts load explicitly in the page and worker before drawing. Both renderers use the same caption layout and a transparent caption layer at actual video dimensions. Decoded frame timestamps drive the source overlay, and the conversion worker receives output-relative sample timestamps. Multiple active cues are combined in source order. + +Burn-in forces actual video encoding even for an unchanged full-source MP4. If the source cannot decode/encode directly, FFmpeg locally prepares H.264/AAC for the selected interval, then the shared browser caption renderer composes the text. This path needs a supported browser H.264 encoder; it does not claim that FFmpeg alone can render the selected font/layout. HDR/wide-color re-encoding remains guarded. + +VTT positioning, regions, inline formatting, speaker tags and word timestamps are not retained as styling metadata. The review step reports these conversions. Unreadable blocks require an explicit acknowledgement before valid cues can be applied; invalid source/selection bounds block applying without clamping. Plain text escapes markup characters on delivery and decodes them on import. + +## Verification + +- 27 unit regressions pass, including import encodings, strict timestamp suffixes, Unicode/markup round trips, long words, emoji clusters, safe-area layout, overflow and revision snapshots. +- Lint, TypeScript and the production static build pass. The existing multiple-lockfile workspace-root warning remains. +- The caption-delivery Edge suite passes actual native and compatible MP4 rendering, preserved audio, sidecar-only pixels, cue boundaries, import append/replace/review/undo, out-of-source and invalid-encoding rejection, style persistence, native accessibility track mode, cancelled/failed font jobs, retry, overflow retention, portrait dimensions and mobile layout without uncaught browser errors. +- Glyph comparisons use exact-position white-ink F1 for 320×180 and 360×640 footage (thresholds 0.8/0.75/0.85 for the corresponding fixtures). The deliberately tiny 90×160 fixture has seven-pixel text: a separately declared one-pixel codec tolerance must exceed 0.95 bidirectional ink agreement. Direct main/worker unencoded glyph pixels were identical; strict compressed threshold masks alone were misleading at that size. +- The earlier export-safety, timeline/transcript, scene/marker and real-speech browser suites passed during this implementation session. Cached recognition still works with model downloads blocked, and undo restores a manually corrected name. + +Run npm test, npm run lint and npm run build from frontend. Serve frontend/out; run tests/caption-delivery.mjs with CUTMARK_TEST_URL, CUTMARK_PLAYWRIGHT_PATH and CUTMARK_ARTIFACTS set to the existing Phase 1 fixture folder. The suite creates caption-delivery artifacts there and needs FFmpeg/ffprobe and Edge. + +## Remaining verification and next work + +1. Export resolution/quality policies and presets are implemented and verified in [Phase 3B](phase-3-export.md). +2. Portable backups, identity-checked relinking, names/duplication and storage management are completed in [Phase 3C](phase-3-recovery.md); Phase 4 sequence/repurposing work is next. +3. Check screen readers, native caption track behavior/fullscreen/PiP, touch devices, orientation metadata and high-resolution performance in real recordings. Firefox/Safari and devices with different fallback-script fonts remain unverified. +4. Add word emphasis only when reliable editable word timings are available. Keep multi-range joins, reframing/audio/effects and advanced queues in their later phases. diff --git a/docs/phase-4.md b/docs/phase-4.md new file mode 100644 index 0000000..62fafcf --- /dev/null +++ b/docs/phase-4.md @@ -0,0 +1,46 @@ +# Phase 4 — Repurposing and richer edits + +The Phase 4 core flows are implemented: keep/reorder/remove sections, extract named clips, prepare common aspect ratios, reposition and orient footage, adjust exported audio, and finish with timed titles and local transparent images. The simple one-range editor remains the default. Advanced panels are collapsed until needed. + +## Implementation and verification + +| Backlog | Current behavior | Verification | +| --- | --- | --- | +| 41–42 | Up to 100 ordered ranges from one source; select a range for timeline editing, split at the source playhead, remove/reorder ranges. Ripple behavior always closes gaps. One committed drag is one undo action. | Actual reordered exports, split/clipped captions, first/last frames, undo/reopen and portable recovery. | +| 43 | Up to 20 named primary-source clips with independent ranges and captured framing/resolution/quality/audio/naming preferences; decoded thumbnails and sequential export jobs. | Separate playable files, per-job progress, cancellation/failure preserving earlier clip downloads, later jobs continuing. | +| 44–45 | Explicit source/range identities, repeated occurrences and source-to-output mapping. Hard cuts are the implemented join. | A 100-range off-grid VFR sequence produces exactly 40 seconds and 300 intended frame intervals; join timestamps and first/last pixels are checked in native and compatible paths. | +| 46–49 | Original, 16:9, 9:16, 1:1, 4:5 and bounded custom ratios; fit/fill, solid background, scale, drag/keyboard/numeric positioning, reset, adjustable preview-only safe guides. | Real crop/rotation/flip pixels through both renderers; one-action drag undo; guides absent from MP4; mobile overflow checks. | +| 51–53 | Listening volume is separate from exported gain. Export gain −24 to +12 dB, mute while retaining the track, optional track omission, and sequence-wide fades. Opt-in live primary-source sample meter and decoded peak/clipping guidance. | Measured −6 dB output change, fades at the sequence ends, audio at joins, live meter activity. No true-peak or LUFS claim. | +| 54 | Measured primary-source peak-bin pause suggestions with threshold, minimum pause and speech padding. Review/select before applying; undo restores the whole sequence. | Actual tone/silence/tone decoding, padded intervals and reversible removal. | +| 56–58 | Timed editable titles/lower thirds, local transparent PNG/JPEG/WebP overlays, rotation and flips. Images are separate stored assets. | Real title timing and pixels, transparency, title/logo/caption layering, source/composed capture, saved decisions and image relinking/bundled recovery. | +| 60 | Decoded source frame or composed frame PNG; filenames include the actual source-frame timestamp. | Downloaded PNGs and recorded dimensions; captures exclude guides. | + +## Timing and rendering contract + +Ranges are continuous half-open time intervals [start, end). A video frame already active at the in point is retained with its interval shortened; the final included frame is shortened at the out point. Output offsets are the sum of requested range lengths, not guesses based on nominal FPS or encoded segment sizes. This matters for VFR and cuts between source timestamps. + +The ordered renderer runs in a worker and encodes explicit presentation timestamps/durations. It keeps decoded color planes for plain same-source cuts. Framing and overlays use the same geometry/layout as the source preview. Images, then titles, then captions are drawn above framed footage. Each layer is evaluated at the output frame timestamp. + +Joined audio is written to one continuous 48 kHz stereo timeline. Mono is copied to left/right at its listening level; stereo is retained. Native resampling uses bounded linear interpolation when sample rates differ. Unsupported/multichannel inputs can use compatible preparation. Gain and linear fades use the assembled output clock; they do not restart at each cut. Missing source audio becomes actual silence when the assembly needs an audio track. AAC encoder padding can affect the final container endpoint slightly; it does not accumulate at joins. + +Automatic rendering first tries supported browser decoding/encoding. Compatible preparation transcodes original sources locally, preserves VFR timestamps, then uses the same exact range renderer. Whole-source preparation can be more expensive than a simple trim. Accurate joined output and composed layers require a supported browser video encoder; the app reports that limitation instead of silently falling back to approximate joins or dropping effects. Ordinary unchanged MP4 reuse and the basic FFmpeg path remain available where applicable. + +Canvas dimensions derive from the primary source and selected resolution limit. A different aspect ratio changes that canvas; fill/zoom can enlarge and crop footage. Reframing preserves image proportions apart from even-pixel output rounding. Guides are adjustable approximations, not platform-certified placement templates. + +The main player is a source editing view. Render sequence/adjusted preview produces the actual composed output using current export settings, including gain/fades. Native caption accessibility mode shows the unframed source; it is labelled separately. Picture-in-picture also shows the source. Other preview players pause when a new player starts. + +## Persistence, cancellation and resource limits + +Ranges, styles, framing, gain/fades, named clips, titles and image references are editing decisions. Their changes participate in named undo/redo and autosave. Previous successful main and clip files remain available until validated replacements succeed. Clip result URLs are session resources; clip definitions survive reload, but those individual result files must be downloaded before leaving the project. + +Portable version 1 single-range files remain supported. Version 2 adds sequence/framing/audio/title/clip/naming decisions. Version 3 includes multi-source assemblies and external image references. See [Phase 5](phase-5.md) for the complete version-3 bundle contract. + +Each image is limited to 2 MiB, 4096 pixels per edge and 8 megapixels; composed requests are limited to 16 megapixels of distinct decoded images. Up to five image layers and ten titles are allowed in the primary edit. Image signatures and dimensions are checked; SVG and remote image URLs are not accepted. Decoded bitmaps, frames, workers and temporary files are released on completion/replacement/cancellation. Backups package Blob assets rather than base64 media. + +## Conditional extensions still deferred + +These controls are not presented as working features: dissolves/crossfades, blurred backgrounds, animated crop/keyframes/tracking, imported music/ducking and color grading/white balance. They remain conditional backlog extensions requiring independent preview/render/performance verification. The completed Phase 4 scope is the practical hard-cut, static-framing, essential-audio and restrained finishing workflow above. + +Run frontend/tests/sequences.mjs, framing.mjs, finishing.mjs, images.mjs and join-precision.mjs against a completed static build. The suites require Edge, Playwright, FFmpeg/ffprobe and the shared fixture folder. Native-frame capture, rotation metadata from real phones, long/high-resolution multi-source recordings, real touch/screen-reader sessions and Firefox/Safari remain manual coverage gaps. + +Final local run: **50 unit tests and all 14 browser suites passed**, with lint, TypeScript and production build successful. See the [dated verification record](verification-2026-10-14.md) for scope and limits. diff --git a/docs/phase-5.md b/docs/phase-5.md new file mode 100644 index 0000000..2de2b4c --- /dev/null +++ b/docs/phase-5.md @@ -0,0 +1,48 @@ +# Phase 5 — Advanced workflow + +The selected Phase 5 workflows are implemented: a small multi-source media bin, reviewable transcript and pause edits, sequential clip exports, reusable output/naming presets, and local handoff artifacts. This phase does not assert that every optional P3 idea in the 100-item product backlog has shipped. + +| Backlog | Implemented workflow | Relevant verification | +| --- | --- | --- | +| 96 | Up to four original local sources; ordered source/range references; common output canvas; per-source caption import/correction; exact mixed-rate joins; source relinking and portable all-source recovery. | Mixed portrait/landscape 24/30 fps, no-audio input becoming silence, source captions mapped after joins, reload, missing-media recovery, fresh-profile lightweight and bundled restore/export. | +| 97 | Selected source cues propose keeping a passage or removing its interior. Configurable speech padding, review playback, explicit apply/discard and whole-sequence undo. | Actual keep/remove proposals and preserved corrected wording; interval subtraction and mapping tests. | +| 98 | Measured pause candidates and decoded sample-peak/clipping feedback. No automatic filler-word or deliberate-language removal. | Tone/silence/tone analysis and padded removals; cancellation and immutable source decisions. | +| 99 | Sequential immutable-revision primary-source clip jobs; per-job progress and cancellation; later jobs proceed after failure; previous downloads retained. Local validated presets capture caption style/delivery/guidance, framing, audio, export quality and MP4 prefix/suffix rules. | Actual multi-file outputs, failed/cancelled job safety, preset application/undo, saved naming rules and immutable previous filenames. | +| 100 | Source/composed PNG captures and JSON edit report with actual timecoded ranges, source references, marker notes/occurrences, captions, layers, framing/audio and clip definitions. | Real downloads and content inspection. The report is a review artifact; portable project files are the editable handoff. | + +## Source identity and corrections + +Assembly ranges refer to stable source identifiers. Captions and markers stay in each source's timebase; output times are derived from range order. Repeated ranges repeat the relevant content. Cue intersections are clipped/split into output intervals without changing source wording. + +Adding the primary selection links its captions, titles, images and markers to the current primary edit. Later primary corrections update the assembly atomically, including undo/redo. The user can explicitly turn off that link to freeze an independent version. Other imported sources have independent cue corrections. Older version-3 documents without the binding flag retain their stored independent decisions rather than silently overwriting text. + +The primary timeline continues editing the primary source. Assembly range controls edit the assembled sequence. Main Export video/current subtitle files use the assembly only when its explicit export-mode checkbox is enabled. Named clip jobs remain primary-source deliverables and capture their own framing/output preferences; global captions/audio are captured when the queue starts. + +Source review uses native caption appearance. Render assembly preview is the reference for composed appearance and final audio adjustments. The shared canvas derives its pixel limit from the primary source; different source dimensions are fitted/filled using the chosen framing. Assemblies encode stereo audio. No arbitrary-source HDR conversion, cloud processing or account is introduced. + +## Storage and portable format + +IndexedDB database version is 7; the existing editable metadata schema remains 2. Upgrades preserve the original projects/assets and legacy recent records. Older tabs cannot reopen the upgraded database to overwrite newer decisions. Open connections close on version changes; blocked upgrades and stale clients receive actionable messages while live data/downloads remain intact. + +Project metadata contains small source/image references and decisions. Large blobs live in the assets store. Primary/additional video, images and outputs retain shared ownership across duplicated projects. Garbage collection uses every remaining project's references, including independent assembly image layers. Autosave checks asset keys without loading/writing unchanged blobs; unused asset collection compares asset identities, not caption wording. Metadata and new assets commit in one transaction; abort/quota failure preserves the prior valid record. + +The lightweight UTF-8 JSON format is cutmark-project with version 1, 2 or 3. A source-inclusive bundle keeps the eight-byte CMARK001 magic, four-byte little-endian JSON length and JSON header, then: +1. The primary source bytes. +2. Each additional distinct video/image asset once, in the deterministic order returned by the manifest references. +Assets identical to the primary source are not appended again. References record byte length, dimensions/media metadata, original/saved-export provenance where applicable, and the existing sha256-chunks-4m-v1 content fingerprint. Image references carry kind=image and validated local raster MIME/dimensions. Source-inclusive packaging remains Blob-backed; it does not build a base64 or ZIP copy of all media. + +Header metadata is bounded to 16 MiB, with validated intervals/IDs/styles, source relationships and supported features. Conflicting metadata for an identical fingerprint, missing/truncated bytes and unknown versions are rejected. Recovery checks the primary video and every additional video/image against content and metadata before saving a separate recovered project. Lightweight recovery can relink assets individually. Images are verified by actual file signature, dimensions and content digest. + +Portable files exclude earlier rendered MP4s, session undo history, transient clip results, preview caches and models. A source-inclusive bundle must have every referenced asset; lightweight files can retain missing references for later relinking. Browser retention is not a backup. + +## Verification and limits + +The production verification set comprises 50 unit tests and fourteen browser scripts: browser, phase2, scenes, speech, caption-delivery, export-settings, project-backups, storage-tools, sequences, assembly, framing, finishing, images and join-precision. Build the static app once before starting the browser run; do not replace frontend/out while browser pages are loading hashed chunks. Record actual per-suite results in the run, rather than treating the presence of these scripts as proof. + +Automatic/compatible encoding is verified on the available Windows Edge runtime. Exact joins are also checked over 100 off-grid VFR ranges. Caption/title/image layers share rendering logic; unchanged frames can keep their decoded color planes. RGB compositing involves normal SDR canvas/color conversion and chroma resampling, so encoded files are not pixel-lossless. Unverified HDR/wide-color re-encoding remains blocked. + +Remaining manual coverage includes Safari/Firefox, real mobile orientation metadata, screen readers/touch hardware, multi-gigabyte/high-resolution assemblies and long-running heap/GPU profiling. Compatible preparation can require significant memory and time; there is no claim of unlimited input size. Queued clip result files are available for the current open project session; this is not a crash-resumable background queue or an automatic publishing service. + +Conditional future extensions include word-level animation with trustworthy timings, more speech models/languages after benchmarking, transitions, moving crops/tracking, music/ducking, blur backgrounds, color grading, proxy generation, automated filler/black-frame analysis and professional interchange formats. The user-facing controls for these deferred extensions have not been added. + +Final local run: **50 unit tests and all 14 browser suites passed**, with lint, TypeScript and production build successful. See the [dated verification record](verification-2026-10-14.md) for scope and limits. diff --git a/docs/verification-2026-10-14.md b/docs/verification-2026-10-14.md new file mode 100644 index 0000000..a8bc601 --- /dev/null +++ b/docs/verification-2026-10-14.md @@ -0,0 +1,38 @@ +# Final local verification — 14 October 2026 + +The completed static production build was served on Windows at http://127.0.0.1:4174 and tested with headless Microsoft Edge. Production files remained unchanged throughout the final browser run. + +## Results + +- Unit tests: **50 passed, 0 failed**. +- ESLint: passed. +- Production build: passed, including TypeScript and static page generation. +- Git whitespace/error check: passed. +- Production browser suites: **14 passed**. + +| Suite | Verified behavior | +| --- | --- | +| browser | Draft/export separation, corrections, undo, cancellation, quota rollback, legacy recovery, corrupt-output rejection, fractional/VFR/portrait/one-frame/hour timing, silence and HDR guard. | +| phase2 | Decoded thumbnails/waveform, zoom/scrubbing/snapping, transport, caption operations/review, persistence and mobile/reduced-motion layout. | +| scenes | Source cuts, refinement, coverage, reviewed/manual decisions, cancellation/failure/retry and long transcript follow. | +| speech | Actual English-model download and recognition, source timestamps, network-blocked cached retry, undo restoring corrections. | +| caption-delivery | Reviewed SRT/VTT imports, Unicode, rendered caption timing/layout, fonts/failure recovery, native accessibility view and portrait delivery. | +| export-settings | Actual quality/file-size changes, source/detail preservation, resolution/audio intent, caption pixels, settings undo/reopen. | +| project-backups | Names, duplication/shared media, old-format migration, lightweight/bundled recovery, exact-content relinking, quota/cancel safety. | +| storage-tools | Targeted model cleanup, unrelated-cache/media preservation, cancellation and requested retention outcomes. | +| sequences | Real reordered picture/audio, split captions, framing/gain/fades, clip queue, individual failure/cancel retaining downloads, presets, captures/report. | +| assembly | Mixed dimensions/rates, missing audio, primary corrections following into assembly, all-source relinking/bundle restore and mobile layout. | +| framing | Crop/rotation/flip pixels in both processing paths, pointer undo, strict decimal entry, guides excluded from export and output naming. | +| finishing | Timed title pixels, preview/export agreement, live sample meter, reviewed transcript edits, measured padded pause removal and undo. | +| images | Transparent image/title/caption composition through both paths, saved external assets, wrong-image rejection, exact relinking and bundled recovery/export. | +| join-precision | 100 off-grid VFR ranges: exactly 40 seconds, 300 retained frame intervals, correct boundary frames and no cumulative timestamp drift. Known audio pulses stay aligned after 44.1-to-48 kHz conversion through the last join, in both processing paths. | + +The final regression run updated two test expectations to match the completed UI: commit numeric guidance on blur, and expect verification of all source contents during recovery. Both affected suites were rerun successfully. No production changes were required by these two failures. + +## Scope and limits + +All five recommended phases have their core flows implemented. [The backlog checklist](backlog.md) identifies conditional extensions that remain deferred. This record does not claim every one of the 100 backlog items or every device/codec is complete. + +Safari/Firefox, real touch devices/phone orientation metadata, screen readers, other-script font coverage, multi-gigabyte/high-resolution inputs and sustained resource profiling still need manual coverage. RGB-composited SDR video is not pixel-lossless; unverified HDR/wide-color encoding is blocked. Browser retention is not a backup, and clip output downloads are session-scoped. + +The work is local; it has not been published to cutmark.dev. diff --git a/frontend/app/globals.css b/frontend/app/globals.css index f0e72fd..64dcd31 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -52,7 +52,39 @@ body { transition-duration: 0.01ms !important; } .progress-light { - animation-duration: 1.4s !important; - animation-iteration-count: infinite !important; + animation: none !important; + opacity: 0.7; } } + +.timeline-tool { overflow-wrap: anywhere; max-width: 100%; min-height: 40px; padding: .5rem .7rem; border: 1px solid #42434f; border-radius: 6px; background: #1b1c23; color: #d4d1df; font-size: 12px; line-height: 1.4; } +.timeline-tool:hover { background: #2a2736; color: #f3f2f7; } +.timeline-tool:disabled { opacity: .4; cursor: default; } +.timeline-tool[aria-pressed="true"] { border-color: #b8a5ff; background: #322b45; color: #f3f2f7; } +.workspace-desktop-tabs, .workspace-inspector { display: none; } +.workspace[data-workspace-tool="captions"] .workspace-trim, .workspace[data-workspace-tool="details"] .workspace-trim { display: none; } +.workspace[data-workspace-tool="captions"] .workspace-inspector, .workspace[data-workspace-tool="details"] .workspace-inspector { display: block; } +.workspace[data-workspace-tool="captions"] .workspace-details, .workspace[data-workspace-tool="details"] .workspace-captions { display: none; } +main:has([data-editor-active="true"]) .intro-banner { padding-block: .65rem; } +main:has([data-editor-active="true"]) .intro-banner h1 { font-size: 16px; } +main:has([data-editor-active="true"]) .intro-banner p { display: none; } +@media (min-width: 1280px) { + .workspace-mobile-tabs { display: none; } + .workspace-desktop-tabs { display: flex; } + .workspace-inspector, .workspace .workspace-trim { display: block !important; } + .workspace .workspace-captions, .workspace .workspace-details { display: none; } + .workspace [data-inspector-visible="true"] { display: block !important; } +} +@media (pointer: coarse) { .timeline-tool { min-height: 44px; } } + +@media (max-width: 1279px) { + .workspace-mobile-tabs { position: sticky; top: 0; z-index: 20; background: #1b1c22; } + .workspace[data-workspace-tool="captions"] .source-preview, + .workspace[data-workspace-tool="details"] .source-preview { max-height: 150px; } + .workspace[data-workspace-tool="captions"] .transport-secondary, + .workspace[data-workspace-tool="details"] .transport-secondary { display: none; } + .workspace[data-workspace-tool="captions"] .transport-primary > button:nth-child(n+4), + .workspace[data-workspace-tool="details"] .transport-primary > button:nth-child(n+4) { display: none; } +} + +.source-preview:fullscreen { width:100vw; height:100vh; max-height:none !important; aspect-ratio:auto; border-radius:0; } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 35782e1..b2d2280 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -17,7 +17,7 @@ export default function Home() { -
+

Make a clean cut.

Runs locally in your browser.

diff --git a/frontend/app/privacy/page.tsx b/frontend/app/privacy/page.tsx index 3f34682..4f3f525 100644 --- a/frontend/app/privacy/page.tsx +++ b/frontend/app/privacy/page.tsx @@ -13,7 +13,7 @@ export default function PrivacyPage() {

Videos and project data

Video editing, trimming, scene analysis, and speech recognition run in your browser on your device. Cutmark does not send your source video, exported video, audio, or transcript to its application servers as part of the editor workflow. Speech recognition uses a model that may be downloaded from Hugging Face the first time you use that feature; the model download does not include your video or audio.

-

To show recent projects and let you reopen them, Cutmark saves up to five projects in this browser’s local IndexedDB storage. A saved project can include the source clip, exported clip, file name, trim range, scene markers, caption text, and processing status. This data stays in the browser profile on this device. Use the delete control beside a recent project to remove it, or clear this site’s browser storage to remove all saved projects. Browser storage can be cleared or evicted by the browser at any time.

+

To show recent projects and let you reopen them, Cutmark autosaves unfinished projects and retains saved projects in this browser’s local IndexedDB storage until you delete them. Cutmark does not automatically delete older projects when saving a new one. A saved project can include source videos, local image assets, exported clips, file names, ranges, captions, markers/notes, framing/audio/layer settings, and processing status. This data stays in the browser profile on this device. Use the delete control beside a recent project to remove it, or clear this site’s browser storage to remove all saved projects. Browser storage can be cleared or evicted by the browser at any time.

diff --git a/frontend/components/AdjustmentPanel.tsx b/frontend/components/AdjustmentPanel.tsx new file mode 100644 index 0000000..6edd789 --- /dev/null +++ b/frontend/components/AdjustmentPanel.tsx @@ -0,0 +1,28 @@ +"use client"; +import NumericField from "./NumericField"; +import CommitSlider from "./CommitSlider"; +import {DEFAULT_FRAMING,DEFAULT_AUDIO,type Framing,type AudioAdjustments} from "@/lib/adjustments"; +export default function AdjustmentPanel({framing,audio,hasAudio,disabled,onChange,onPreview}:{framing:Framing;audio:AudioAdjustments;hasAudio:boolean;disabled:boolean;onChange:(f:Framing,a:AudioAdjustments,action:string,group?:string)=>void;onPreview:()=>void}){ + const f=(value:Partial,action="Change framing")=>onChange({...framing,...value},audio,action,value.background!==undefined?"framing-background":undefined); + const a=(value:Partial)=>onChange(framing,{...audio,...value},"Change exported audio"); + return
Framing and audio · {framing.ratio} · {audio.mute?"muted":audio.gainDb+" dB"} +

Framing changes the output canvas. Fill crops the image; Fit preserves it with a solid background. Positive gain may clip. Listening volume is separate; render a preview to hear exported gain and fades across the whole sequence.

+
Framing + + + {framing.ratio==="custom"&&(["customWidth","customHeight"] as const).map(key=>)} + {(["x","y","zoom"] as const).map(key=>)} + + +
{(["flipX","flipY"] as const).map(key=>)}
+ +
+
Exported audio {hasAudio?"":"· no source track"} + + + {(["fadeIn","fadeOut"] as const).map(key=>)} + +
+ {audio.gainDb>0&&!audio.mute&&

Gain above 0 dB may exceed the signal ceiling. Inspect the rendered preview before delivery.

} +
; +} diff --git a/frontend/components/AssemblyPanel.tsx b/frontend/components/AssemblyPanel.tsx new file mode 100644 index 0000000..cefd452 --- /dev/null +++ b/frontend/components/AssemblyPanel.tsx @@ -0,0 +1,38 @@ +"use client"; +import {pauseOtherPreviews} from "@/lib/playback"; +import {useEffect,useRef,useState} from "react"; +import type {Assembly,AssemblySource} from "@/lib/assembly"; +import {formatTime,sequenceDuration,validateRange} from "@/lib/timing"; +import {serializeCaptions,type CaptionCue} from "@/lib/captions"; +import TranscriptEditor from "./TranscriptEditor"; +import type {CaptionGuidance} from "@/lib/caption-editing"; +import type {ImageOverlay} from "@/lib/image-tools"; +import SubtitleImport from "./SubtitleImport"; +import TimeField from "./TimeField"; +function SourceReview({blob,source,onVideo,onTime}:{blob?:Blob;source:AssemblySource;onVideo:(video:HTMLVideoElement|null)=>void;onTime:(time:number)=>void}){ + const [url,setUrl]=useState(null); + useEffect(()=>{if(!blob)return;const url=URL.createObjectURL(blob); + // eslint-disable-next-line react-hooks/set-state-in-effect -- Synchronize a local source preview resource. + setUrl(url);return()=>URL.revokeObjectURL(url);},[blob]); + return url?:

Missing media. Relink the matching source before rendering.

; +} +export default function AssemblyPanel({assembly,media,primaryAssetId,guidance,saveStatus,onGuidance,onPrimaryCaptions,onRelinkImage,disabled,onPrimary,onImport,onRelink,onChange,onPreview}:{assembly?:Assembly;media:Record;primaryAssetId:string;guidance:CaptionGuidance;saveStatus:string;onGuidance:(guidance:CaptionGuidance)=>void;onPrimaryCaptions:(cues:CaptionCue[],group?:string,action?:string)=>void;onRelinkImage:(image:ImageOverlay,file:File)=>void;disabled:boolean;onPrimary:()=>void;onImport:(file:File)=>void;onRelink:(source:AssemblySource,file:File)=>void;onChange:(assembly:Assembly,action:string,group?:string)=>void;onPreview:()=>void}){ + const videoRefs=useRef(new Map()),[sourceTimes,setSourceTimes]=useState>({}); + const updateCues=(id:string,captions:CaptionCue[],group?:string,action="Edit source captions")=>{if(assembly?.sources.find(source=>source.id===id)?.primary){onPrimaryCaptions(captions,group,action);return;}if(assembly)onChange({...assembly,sources:assembly.sources.map(source=>source.id===id?{...source,captions}:source)},action,group?"source-"+id+":"+group:undefined);}; + return
Multiple-source assembly · {assembly?.sources.length??0} sources +

Assemble up to four local sources with hard cuts. Each range references its original source. All sources share the primary canvas, caption style, quality and final audio adjustments. Primary sources follow later corrections unless you turn off the link. Assembly audio is stereo. Sources without audio contribute silence when another source supplies audio. Preparing several sources increases memory use in the compatible processor.

+
+ {assembly&&<>

{assembly.enabled?"The primary timeline remains a source editing view; the export action now renders this assembly.":"The export action uses primary kept ranges. Assembly sources and decisions remain saved."} · Assembly duration {formatTime(sequenceDuration(assembly.ranges))}

+
    {assembly.sources.map(source=>
  • {source.reference.fileName} · {source.reference.metadata.width} × {source.reference.metadata.height} · {formatTime(source.reference.metadata.duration)} · {source.reference.metadata.hasAudio?"audio":"no audio"}

    + {if(video)videoRefs.current.set(source.id,video);else videoRefs.current.delete(source.id);}} onTime={time=>setSourceTimes(previous=>({...previous,[source.id]:time}))}/> + {(source.primary||source.assetId===primaryAssetId||source.reference.fingerprint===assembly.sources.find(s=>s.primary)?.reference.fingerprint)&&} + {(source.images??[]).filter(image=>!media[image.reference.fingerprint]).map(image=>)} + {!media[source.reference.fingerprint]&&} +
    + updateCues(source.id,cues,group,action)}/>
    Edit captions for {source.reference.fileName}(sourceTimes[source.id]??0)>=cue.start&&(sourceTimes[source.id]??0)range.sourceId===source.id)} guidance={guidance} onGuidance={onGuidance} saveStatus={saveStatus} getPlayhead={()=>videoRefs.current.get(source.id)?.currentTime??0} onSeek={time=>{const video=videoRefs.current.get(source.id);if(video&&video.readyState>=1){video.pause();video.currentTime=time;}}} onChange={(cues,group,action)=>updateCues(source.id,cues,group,action)}/>

    Source review uses native caption appearance. Render the assembly to inspect the output styling and audio adjustments. {source.captions.length} source cues. {source.primary?"Primary corrections remain linked to the main caption editor.":"This source has independent caption corrections."}

  • )}
+
    {assembly.ranges.map((range,index)=>{const source=assembly.sources.find(s=>s.id===range.sourceId);if(!source)return null;return
  1. Range {index+1} · {source.reference.fileName} · {formatTime(range.end-range.start)}

    {(["start","end"] as const).map(point=>)} + {([-1,1]).map(direction=>)} +
  2. })}
+ } +
; +} diff --git a/frontend/components/AudioMeter.tsx b/frontend/components/AudioMeter.tsx new file mode 100644 index 0000000..0710f58 --- /dev/null +++ b/frontend/components/AudioMeter.tsx @@ -0,0 +1,18 @@ +"use client"; +import {useEffect,useRef,useState,type RefObject} from "react"; +export default function AudioMeter({videoRef}:{videoRef:RefObject}){ + const graphRef=useRef<{context:AudioContext;analyser:AnalyserNode;source:MediaElementAudioSourceNode;video:HTMLVideoElement}|null>(null),frameRef=useRef(0),pendingRef=useRef(false),activeRef=useRef(false),mountedRef=useRef(true); + const [enabled,setEnabled]=useState(false),[peak,setPeak]=useState(0),[message,setMessage]=useState(""); + useEffect(()=>{mountedRef.current=true;return()=>{mountedRef.current=false;activeRef.current=false;cancelAnimationFrame(frameRef.current);const graph=graphRef.current;graph?.source.disconnect();void graph?.context.close();};},[]); + const toggle=async()=>{ + if(activeRef.current){activeRef.current=false;cancelAnimationFrame(frameRef.current);setEnabled(false);return;} + if(pendingRef.current)return;const video=videoRef.current;if(!video)return;pendingRef.current=true; + try{ + if(!graphRef.current||graphRef.current.video!==video){if(graphRef.current)await graphRef.current.context.close();const context=new AudioContext(),source=context.createMediaElementSource(video),analyser=context.createAnalyser();analyser.fftSize=2048;source.connect(analyser);analyser.connect(context.destination);graphRef.current={context,source,analyser,video};} + await graphRef.current.context.resume();if(!mountedRef.current)return; + activeRef.current=true;setEnabled(true);setMessage("");const samples=new Float32Array(2048);let last=0; + const measure=(now:number)=>{if(!activeRef.current)return;const graph=graphRef.current;if(graph&&now-last>=100){graph.analyser.getFloatTimeDomainData(samples);let maximum=0;for(const sample of samples)maximum=Math.max(maximum,Math.abs(sample));setPeak(maximum);last=now;}frameRef.current=requestAnimationFrame(measure);};frameRef.current=requestAnimationFrame(measure); + }catch(error){if(mountedRef.current)setMessage(error instanceof Error?error.message:"The listening meter is unavailable.");}finally{pendingRef.current=false;} + }; + return
Listening level meter

This measures the primary source preview. It is a sample-peak listening meter, not a true-peak or loudness-standard measurement. Preview volume affects listening; exported gain is adjusted separately.

{enabled&&

{peak>0?(20*Math.log10(peak)).toFixed(1)+" dBFS":"Silent / paused"}{peak>=1?" · Signal at or above clipping ceiling":""}

}{message&&

{message}

}
; +} diff --git a/frontend/components/CaptionStylePanel.tsx b/frontend/components/CaptionStylePanel.tsx new file mode 100644 index 0000000..280b963 --- /dev/null +++ b/frontend/components/CaptionStylePanel.tsx @@ -0,0 +1,27 @@ +"use client"; +import {CAPTION_PRESETS,type CaptionStyle,type CaptionDelivery} from "@/lib/caption-renderer"; +interface Props {style:CaptionStyle;delivery:CaptionDelivery;disabled:boolean;nativePreview:boolean;onNativePreview:(value:boolean)=>void;onStyle:(style:CaptionStyle,action:string,group?:string)=>void;onDelivery:(delivery:CaptionDelivery)=>void} +export default function CaptionStylePanel(props:Props){ + const patch=(field:keyof CaptionStyle,value:CaptionStyle[keyof CaptionStyle])=>props.onStyle({...props.style,[field]:value},"Change caption style","caption-style-"+field); + const control="min-h-10 rounded border border-line bg-well px-2 text-ink"; + return
Caption style and delivery + +

{props.delivery==="sidecar"?"Styling is a preview; exported video has no added caption pixels. Subtitle files retain words and timings.":"The MP4 will include these captions permanently. Browser video encoding is required; incompatible sources are prepared locally first."}

+ +

Native preview and picture in picture use the source caption track; they do not show the chosen video style.

+ + + + +

Size scales with the frame’s shorter edge. Long words wrap; text is never removed to fit.

+ {(["color","outlineColor","backgroundColor"] as const).map(field=>)} + + + + + + + + Font license +
; +} diff --git a/frontend/components/CleanupPanel.tsx b/frontend/components/CleanupPanel.tsx new file mode 100644 index 0000000..e6bcf66 --- /dev/null +++ b/frontend/components/CleanupPanel.tsx @@ -0,0 +1,35 @@ +"use client"; +import NumericField from "./NumericField"; +import {useEffect,useMemo,useRef,useState} from "react"; +import {silenceSuggestions} from "@/lib/sequence-edits"; +import {formatTime} from "@/lib/timing"; +export default function CleanupPanel({file,duration,gainDb,disabled,onReview,onApply}:{file:File;duration:number;gainDb:number;disabled:boolean;onReview:(start:number,end:number)=>void;onApply:(ranges:{start:number;end:number}[])=>void}){ + const [data,setData]=useState<{peaks:Float32Array;binSeconds:number;maximum:number}|null>(null),[message,setMessage]=useState(""),[working,setWorking]=useState(false); + const [threshold,setThreshold]=useState(-40),[minimum,setMinimum]=useState(.8),[padding,setPadding]=useState(.2),[selected,setSelected]=useState([]); + const workerRef=useRef(null); + useEffect(()=>()=>workerRef.current?.terminate(),[]); + const suggestions=useMemo(()=>data?silenceSuggestions(data.peaks,data.binSeconds,threshold,minimum,padding,duration):[],[data,threshold,minimum,padding,duration]); + const cancel=()=>{workerRef.current?.terminate();workerRef.current=null;setWorking(false);setMessage("Analysis cancelled; source and edits are preserved.");}; + const analyze=()=>{ + workerRef.current?.terminate();const worker=new Worker(new URL("../lib/timeline-analysis.worker.ts",import.meta.url),{type:"module"});workerRef.current=worker;setWorking(true);setMessage("Decoding source samples…"); + const finish=()=>{worker.terminate();if(workerRef.current===worker){workerRef.current=null;setWorking(false);}}; + worker.onmessage=({data:response})=>{ + if(workerRef.current!==worker)return; + if(response.type==="progress")setMessage("Decoding samples… about "+response.percent+"%"); + if(response.type==="waveform"){setData({peaks:response.levels[0],binSeconds:response.binSeconds,maximum:response.maximum});setSelected([]);setMessage("Sample analysis ready. Suggestions are approximate peak-bin intervals, not speech recognition.");finish();} + if(response.type==="absent"||response.type==="error"){setMessage(response.type==="absent"?"This source has no audio track.":response.message);finish();} + }; + worker.onerror=()=>{setMessage("Audio analysis could not run. Source and edits remain available.");finish();}; + worker.postMessage({kind:"waveform",file,duration}); + }; + return
Audio levels and pause suggestions +

Analyze the primary source and review low-amplitude intervals before removing anything. Quiet words, breaths and deliberate pauses may be flagged. Padding keeps material on both sides. Level feedback uses decoded sample peaks; it is not a true-peak or loudness-standard measurement.

+
{working&&}
+ {message&&

{message}

} + {data&&

Decoded source sample peak: {data.maximum? (20*Math.log10(data.maximum)).toFixed(1)+" dBFS":"silent"}. After {gainDb} dB gain: {data.maximum?(20*Math.log10(data.maximum)+gainDb).toFixed(1)+" dBFS":"silent"}.{data.maximum*10**(gainDb/20)>1&& Signal may clip; reduce exported gain.}

} +
{([{key:"threshold",label:"Silence threshold dBFS",value:threshold,min:-60,max:-10,step:1,set:setThreshold},{key:"minimum",label:"Minimum pause seconds",value:minimum,min:.2,max:10,step:.1,set:setMinimum},{key:"padding",label:"Pause speech padding seconds",value:padding,min:0,max:2,step:.05,set:setPadding}]).map(field=>)}
+ {data&&

{suggestions.length} candidates · sample bins about {Math.round(data.binSeconds*1000)} ms

} +
    {suggestions.slice(0,200).map((range,index)=>
  • )}
+ {suggestions.length>200&&

Showing the first 200 candidates; adjust the threshold or minimum pause to narrow the list.

} +
; +} diff --git a/frontend/components/ClipQueue.tsx b/frontend/components/ClipQueue.tsx new file mode 100644 index 0000000..4483ac7 --- /dev/null +++ b/frontend/components/ClipQueue.tsx @@ -0,0 +1,32 @@ +"use client"; +import {pauseOtherPreviews} from "@/lib/playback"; +import {useEffect,useState} from "react"; +import TimeField from "./TimeField"; +import {formatTime,validateRange} from "@/lib/timing"; +import type {ClipDefinition,ClipOutput} from "@/lib/clip-tools"; +export default function ClipQueue({file,clips,outputs,revision,duration,disabled,onChange,onAdd,onRun,onCancel}:{file:File;clips:ClipDefinition[];outputs:ClipOutput[];revision:number;duration:number;disabled:boolean;onChange:(clips:ClipDefinition[],action:string)=>void;onAdd:()=>void;onRun:()=>void;onCancel:()=>void}){ + const [images,setImages]=useState>({}); + const times=clips.map(clip=>clip.range.start).join(","); + useEffect(()=>{ + if(!times)return; + const urls:string[]=[];let active=true; + const worker=new Worker(new URL("../lib/timeline-analysis.worker.ts",import.meta.url),{type:"module"}); + worker.onmessage=({data})=>{if(active&&data.type==="thumbnail"){const url=URL.createObjectURL(data.blob);urls.push(url);setImages(previous=>({...previous,[data.time]:url}));}}; + worker.postMessage({kind:"thumbnails",file,times:times?[...new Set(times.split(",").map(Number))]:[]}); + return()=>{active=false;worker.terminate();urls.forEach(url=>URL.revokeObjectURL(url));}; + },[file,times]); + const update=(id:string,values:Partial,action="Change clip definition")=>onChange(clips.map(c=>c.id===id?{...c,...values}:c),action); + return
Named clips and export queue · {clips.length} +

Add the selected range as a named deliverable. Each clip captures its framing and export settings. Captions and audio changes are captured when the queue starts. Jobs run one at a time; a failed job leaves other results available. Downloads remain here while this project is open; clip definitions save with the project.

+
{outputs.some(o=>o.status==="working")&&}
+
    {clips.map((clip,index)=>{const output=outputs.find(o=>o.id===clip.id);return
  • + {/* These are actual locally decoded thumbnail URLs. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {images[clip.range.start]&&{"Source} +
    update(clip.id,{selected:e.target.checked})}/>
    +
    {(["start","end"] as const).map(point=>)}
    +

    {formatTime(clip.range.end-clip.range.start)} · {clip.framing.ratio} · {clip.preferences.resolution} resolution · {clip.preferences.quality} · {clip.preferences.audio==="keep"?"keep audio":"no audio"}

    + + {output&&

    {output.status} · revision {output.revision} · {output.message}

    }{output?.url&&
    {output.exportRevision===revision?"Latest clip output":"Earlier clip output"} · revision {output.exportRevision}Download {clip.name} MP4{output.captions&&Clip subtitles}
    Review clip output
    } +
  • })}
; +} diff --git a/frontend/components/CommitSlider.tsx b/frontend/components/CommitSlider.tsx new file mode 100644 index 0000000..d5bb57d --- /dev/null +++ b/frontend/components/CommitSlider.tsx @@ -0,0 +1,9 @@ +"use client"; +import {useRef,useState} from "react"; +export default function CommitSlider({label,value,min,max,step=1,onCommit}:{label:string;value:number;min:number;max:number;step?:number;onCommit:(value:number)=>void}){ + const [draft,setDraft]=useState(null),dragRef=useRef(false); + return {dragRef.current=true;event.currentTarget.setPointerCapture(event.pointerId);setDraft(value);}} + onChange={event=>{const value=Number(event.target.value);if(dragRef.current)setDraft(value);else onCommit(value);}} + onPointerUp={event=>{if(dragRef.current){dragRef.current=false;onCommit(Number(event.currentTarget.value));setDraft(null);event.currentTarget.releasePointerCapture(event.pointerId);}}} + onPointerCancel={()=>{dragRef.current=false;setDraft(null);}} className="mt-2 min-h-10 w-full"/>; +} diff --git a/frontend/components/Editor.tsx b/frontend/components/Editor.tsx index 2698d71..f1cfe45 100644 --- a/frontend/components/Editor.tsx +++ b/frontend/components/Editor.tsx @@ -1,34 +1,55 @@ "use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; +import {DEFAULT_FRAMING,DEFAULT_AUDIO,type Framing,type AudioAdjustments} from "@/lib/adjustments"; +import {subtractInterval} from "@/lib/sequence-edits"; +import {pauseOtherPreviews} from "@/lib/playback"; +import ImagePanel from "./ImagePanel"; +import {imageMime,bindImages,loadImageLayers,drawImageLayers,closeImageLayers,type ImageOverlay} from "@/lib/image-tools"; +import AudioMeter from "./AudioMeter"; +import type {TitleCue} from "@/lib/title-tools"; +import TitlePanel from "./TitlePanel"; +import AssemblyPanel from "./AssemblyPanel"; +import {assemblyCaptions,assemblyTitles,assemblyImages,assemblyMarkers,validateAssembly,type AssemblySource} from "@/lib/assembly"; +import PresetPanel from "./PresetPanel"; +import CleanupPanel from "./CleanupPanel"; +import ClipQueue from "./ClipQueue"; +import type {ClipOutput} from "@/lib/clip-tools"; +import AdjustmentPanel from "./AdjustmentPanel"; +import SequencePanel from "./SequencePanel"; +import ExportSettings from "./ExportSettings"; +import {DEFAULT_EXPORT_PREFERENCES,outputSpec,outputFileName,type ExportPreferences} from "@/lib/output-spec"; + +import { useCallback, useEffect, useRef, useState, useMemo, useReducer } from "react"; +import StoragePanel from "./StoragePanel"; +import ProjectRecovery from "./ProjectRecovery"; +import {createBackup,backupBlob,fingerprintSource,verifyImageRelink,verifyRelink,restoredProject,projectFileStem,type ProjectBackup} from "@/lib/portable-project"; import UploadZone from "./UploadZone"; import Timeline from "./Timeline"; +import CaptionStylePanel from "./CaptionStylePanel"; +import SubtitleImport from "./SubtitleImport"; +import { DEFAULT_CAPTION_STYLE, type CaptionStyle, type CaptionDelivery } from "@/lib/caption-renderer"; import VideoPreview from "./VideoPreview"; +import ScenePanel from "./ScenePanel"; +import MarkerPanel from "./MarkerPanel"; +import { analyzeScenes } from "@/lib/scene-analysis"; +import { cutsFromLegacy, mergeSceneScan, type SceneCut, type SceneSettings, type EditorialMarker } from "@/lib/scene-tools"; +import Transport from "./Transport"; +import useCoalescedSeek from "./useCoalescedSeek"; +import { captionIssues, type CaptionGuidance } from "@/lib/caption-editing"; import SpecPanel from "./SpecPanel"; import TranscriptEditor from "./TranscriptEditor"; -import { parseCaptions, serializeCaptions, serializeSubRip, type CaptionCue } from "@/lib/captions"; -import { detectScenesLocally, processLocally, type ProcessingPhase, type ProcessingPhaseStatus } from "@/lib/local-processing"; -import { deleteSavedProject, listSavedProjects, saveProject, type SavedProject } from "@/lib/project-history"; +import { parseCaptions, isSubtitleInterval, serializeCaptions, serializeSubRip, type CaptionCue } from "@/lib/captions"; +import { processLocally, processAssembly,transcribeLocally } from "@/lib/local-processing"; +import { duplicateSavedProject, deleteSavedProject, listSavedProjects, loadSavedProject, saveProject, type SavedProject, type SavedProjectSummary } from "@/lib/project-history"; + +import { createHistory, editReducer, type EditDecisions } from "@/lib/editable-project"; +import { formatTime as formatPreviewTime, validateRange, mapCuesToOutput, sourceOccurrences, sequenceDuration, validateSequence } from "@/lib/timing"; +import { probeMedia, validateOutput, adjacentFrame, lastIncludedFrame, type MediaMetadata } from "@/lib/media"; type Status = "IDLE" | "PROCESSING" | "COMPLETE" | "FAILED"; type Result = { - videoUrl: string; - captionsUrl?: string; - captionError?: string; - sceneMarkers: number[]; - videoBlob: Blob; - historyId: string; - createdAt: number; - fileName: string; - duration: number; - sourceVideo: Blob; - sourceDuration: number; - trimStart: number; - trimEnd: number; - includeSceneDetection: boolean; - includeCaptions: boolean; - sceneDetectionSucceeded: boolean; - sourceAvailable: boolean; + videoUrl: string; videoBlob: Blob; artifactId: string; revision: number; trimStart: number; trimEnd: number; + duration: number; settings: {images?:ImageOverlay[];titles?:TitleCue[]; width: number; height: number; audio: boolean; framing?:Framing;audioAdjustments?:AudioAdjustments;preferences?:ExportPreferences; timing: "source"; captions: CaptionDelivery; captionStyle?:CaptionStyle; fileName?:string; processor: "automatic" | "ffmpeg" }; captionsVtt: string | null; metadata?: MediaMetadata; + sceneMarkers: number[]; captionError?: string; }; type StepStatus = "waiting" | "working" | "complete" | "failed" | "skipped"; type Steps = { upload: StepStatus; trimming: StepStatus; sceneDetection: StepStatus; captions: StepStatus }; @@ -37,21 +58,16 @@ function DownloadGlyph() { return ; } -function formatPreviewTime(time: number) { - const minutes = Math.floor(time / 60); - const seconds = (time % 60).toFixed(2).padStart(5, "0"); - return `${String(minutes).padStart(2, "0")}:${seconds}`; -} - function ProcessingSteps({ steps, messages, elapsedSeconds }: { steps: Steps; messages: Partial>; elapsedSeconds: number }) { const items: { key: keyof Steps; label: string }[] = [ - { key: "upload", label: "Upload" }, - { key: "trimming", label: "Trim" }, + { key: "upload", label: "Source" }, + { key: "trimming", label: "Export video" }, { key: "sceneDetection", label: "Scene detection" }, - { key: "captions", label: "Captions" }, + { key: "captions", label: "Transcription" }, ]; const activeItem = items.find(({ key }) => steps[key] === "working"); const doneCount = items.filter(({ key }) => steps[key] === "complete" || steps[key] === "skipped").length; + const completionCopy = steps.trimming === "complete" ? "Video export verified." : steps.captions === "complete" ? "Captions ready to review." : steps.sceneDetection === "complete" ? "Scene scan ready to review." : "Operation finished."; const hasFailed = items.some(({ key }) => steps[key] === "failed"); const progressMatch = activeItem ? messages[activeItem.key]?.match(/about (\d+)%/) : null; const progressPercent = progressMatch ? Number(progressMatch[1]) : null; @@ -62,7 +78,7 @@ function ProcessingSteps({ steps, messages, elapsedSeconds }: { steps: Steps; me

{activeItem ? `${activeItem.label} in progress` : doneCount === items.length ? "Processing complete" : hasFailed ? "Processing finished with an unavailable step" : "Processing steps"}

-

{activeItem ? messages[activeItem.key] ?? "Working on this step… Progress updates as the work completes." : doneCount === items.length ? "Your export is ready." : "Steps update as each part of your video finishes."}

+

{activeItem ? messages[activeItem.key] ?? "Working on this step… Progress updates as the work completes." : doneCount === items.length ? completionCopy : "Steps update as each part of your video finishes."}

{activeItem && {elapsed} elapsed}
@@ -76,445 +92,614 @@ function ProcessingSteps({ steps, messages, elapsedSeconds }: { steps: Steps; me
; } +const DEFAULT_SCENE_SETTINGS: SceneSettings = { mode: "quick", sensitivity: "balanced", scope: "source" }; +const NO_MARKERS: EditorialMarker[] = []; +const DEFAULT_GUIDANCE: CaptionGuidance = { lineLength: 42, charactersPerSecond: 20 }; + export default function Editor() { + const empty: EditDecisions = { ranges: [], captions: [], sceneMarkers: [], scenesDetected: false, includeSceneDetection: false }; + const [assemblyMedia,setAssemblyMedia]=useState>({}); const [file, setFile] = useState(null); + const storageOperation=useRef(null); + const [storageWorking,setStorageWorking]=useState(false); + const [projectWorking,setProjectWorking]=useState(false); + const [projectProgress,setProjectProgress]=useState(""); + const [identity, setIdentity] = useState<{ id: string; createdAt: number; sourceAvailable: boolean;sourceAssetId?:string } | null>(null); const [objectUrl, setObjectUrl] = useState(null); - const [duration, setDuration] = useState(0); - const [trimStart, setTrimStart] = useState(0); - const [trimEnd, setTrimEnd] = useState(0); + const [metadata, setMetadata] = useState(null); + const [history, dispatch] = useReducer(editReducer, createHistory(empty)); + const { present: edits, revision } = history; + const duration = metadata?.duration ?? 0; + const [selectedRange,setSelectedRange]=useState(0); + const [clipOutputs,setClipOutputs]=useState([]); + const clipJobRef=useRef(null),clipUrlsRef=useRef(new Map()); + useEffect(()=>{const urls=clipUrlsRef.current;return()=>{clipJobRef.current?.abort();urls.forEach(url=>URL.revokeObjectURL(url));urls.clear();};},[file]); + const activeRangeIndex=Math.min(selectedRange,Math.max(0,edits.ranges.length-1)); + const activeRange=edits.ranges[activeRangeIndex]; + const trimStart = activeRange?.start ?? 0, trimEnd = activeRange?.end ?? 0; + const primaryImageRender=useMemo(()=>bindImages(edits.images??[],assemblyMedia),[edits.images,assemblyMedia]); + const outputImageRender=useMemo(()=>bindImages(edits.assembly?.enabled?assemblyImages(edits.assembly):mapCuesToOutput(edits.images??[],edits.ranges),assemblyMedia),[edits.assembly,edits.images,edits.ranges,assemblyMedia]); + const outputTitles=edits.assembly?.enabled?assemblyTitles(edits.assembly):mapCuesToOutput(edits.titles??[],edits.ranges); + const sequenceLength=edits.assembly?.enabled?sequenceDuration(edits.assembly.ranges):sequenceDuration(edits.ranges); + const [sequencePreview,setSequencePreview]=useState<{url:string;revision:number;captions:string|null}|null>(null); + useEffect(()=>()=>{if(sequencePreview)URL.revokeObjectURL(sequencePreview.url);},[sequencePreview]); + const changeRange=(start:number,end:number,action:string)=>edit({...edits,ranges:edits.ranges.map((range,index)=>index===activeRangeIndex?{...range,start,end}:range)},action); + const projectTitle=edits.projectTitle?.trim()||file?.name||"New project"; + const outputStem=projectFileStem(file&&projectTitle===file.name?file.name.replace(/\.[^.]+$/, ""):projectTitle); + const captionCues = edits.captions, sceneTimes = edits.sceneMarkers, scenesDetected = edits.scenesDetected; + const sceneCuts = useMemo(() => edits.sceneCuts ?? cutsFromLegacy(sceneTimes), [edits.sceneCuts, sceneTimes]); + const sceneSettings = edits.sceneSettings ?? DEFAULT_SCENE_SETTINGS; + const editorialMarkers = edits.editorialMarkers ?? NO_MARKERS; + const includeSceneDetection = edits.includeSceneDetection; + const captionStyle = edits.captionStyle ?? DEFAULT_CAPTION_STYLE, captionDelivery = edits.captionDelivery ?? "sidecar"; + const burnCaptions = captionDelivery !== "sidecar"; + const exportPreferences=edits.exportPreferences??DEFAULT_EXPORT_PREFERENCES; + const framing=edits.framing??DEFAULT_FRAMING,audioAdjustments=edits.audioAdjustments??DEFAULT_AUDIO; + const spec=metadata?outputSpec(metadata.width,metadata.height,edits.assembly?.enabled?edits.assembly.sources.some(source=>source.reference.metadata.hasAudio):metadata.hasAudio,exportPreferences,framing,audioAdjustments):null; + const [nativeCaptionPreview,setNativeCaptionPreview] = useState(false); + const guidance = edits.captionGuidance ?? DEFAULT_GUIDANCE; + const [tool, setTool] = useState<"trim" | "captions" | "details">("trim"); + const [inspector, setInspector] = useState<"captions" | "details">("captions"); + const [playing, setPlaying] = useState(false), [loopSelection, setLoopSelection] = useState(false); + const [playbackRate, setPlaybackRate] = useState(1), [previewVolume, setPreviewVolume] = useState(1); + const [contextSeconds, setContextSeconds] = useState(1), [reviewMode, setReviewMode] = useState<"source" | "selection" | "context">("source"); + const [lastContext, setLastContext] = useState<{ start: number; end: number } | null>(null); + const [pipSupported, setPipSupported] = useState(false), [modelCache, setModelCache] = useState("Checking local model cache…"); + const reviewBounds = useRef<{ start: number; end: number; kind: "selection" | "context" } | null>(null); + const loopRef = useRef(false); const [currentTime, setCurrentTime] = useState(0); const [status, setStatus] = useState("IDLE"); const [progress, setProgress] = useState(""); const [result, setResult] = useState(null); - const [captionCues, setCaptionCues] = useState([]); - const [recentProjects, setRecentProjects] = useState([]); - const [projectPendingDelete, setProjectPendingDelete] = useState(null); + const [recentProjects, setRecentProjects] = useState([]); + const [projectPendingDelete, setProjectPendingDelete] = useState(null); const [deletingProjectId, setDeletingProjectId] = useState(null); const deleteDialogRef = useRef(null); const [historyMessage, setHistoryMessage] = useState(""); - const [sceneTimes, setSceneTimes] = useState([]); - const [scenesDetected, setScenesDetected] = useState(false); + const [savedSnapshot, setSavedSnapshot] = useState<{ id: string; revision: number; output: Result | null;mediaAssets:Record } | null>(null); + const [saveStatus, setSaveStatus] = useState("Not saved yet"); + const [saveAttempt, setSaveAttempt] = useState(0); const [sceneScanStatus, setSceneScanStatus] = useState("waiting"); const [sceneScanMessage, setSceneScanMessage] = useState(""); + const [captionScope, setCaptionScope] = useState<"selection" | "source">("selection"); + const [captionWorking, setCaptionWorking] = useState(false); const [snapToCuts, setSnapToCuts] = useState(false); - const [includeSceneDetection, setIncludeSceneDetection] = useState(false); - const [includeCaptions, setIncludeCaptions] = useState(false); + const [forceFFmpeg, setForceFFmpeg] = useState(false); const [steps, setSteps] = useState({ upload: "waiting", trimming: "waiting", sceneDetection: "waiting", captions: "waiting" }); const [processingElapsed, setProcessingElapsed] = useState(0); - const processingStartedAt = useRef(null); const [stepMessages, setStepMessages] = useState>>({}); const [errorMessage, setErrorMessage] = useState(null); + const [frameMessage, setFrameMessage] = useState(""); const videoRef = useRef(null); - const resultRef = useRef(null); - const projectIdentity = useRef<{ id: string; sourceAvailable: boolean } | null>(null); - const pendingEditRange = useRef<{ start: number; end: number } | null>(null); - const selectedPreview = useRef(false); - + const { seek: queuedSeek, cancel: cancelSeek } = useCoalescedSeek(videoRef, objectUrl, duration); + const operation = useRef<{ id: number; controller: AbortController } | null>(null); + const generation = useRef(0), frameRequest = useRef(0); + const mounted = useRef(true); + const saveQueue = useRef>(Promise.resolve()); + const persistRef = useRef<(() => Promise) | null>(null); + const busy = storageWorking || projectWorking || status === "PROCESSING" || sceneScanStatus === "working" || captionWorking; + const [sourceCaptionsUrl, setSourceCaptionsUrl] = useState(); + useEffect(() => { + const url = captionCues.length ? URL.createObjectURL(new Blob([serializeCaptions(captionCues)], { type: "text/vtt" })) : undefined; + // eslint-disable-next-line react-hooks/set-state-in-effect -- Synchronize the native caption-track resource with editable cues. + setSourceCaptionsUrl(url); + return () => { if (url) URL.revokeObjectURL(url); }; + }, [captionCues]); + const mappedOutputCues = useMemo(() => edits.assembly?.enabled?assemblyCaptions(edits.assembly):mapCuesToOutput(captionCues, edits.ranges), [captionCues, edits.ranges,edits.assembly]); + const outputCues = useMemo(() => mappedOutputCues.filter((cue) => cue.text.trim() && isSubtitleInterval(cue.start, cue.end)), [mappedOutputCues]); + const shortCaptionIntersections = mappedOutputCues.filter((cue) => cue.text.trim() && !isSubtitleInterval(cue.start, cue.end)).length; + const previewSrc = objectUrl; + const activeCueId = captionCues.find((cue) => currentTime >= cue.start && currentTime < cue.end)?.id; + const reviewIssues = useMemo(() => captionIssues(captionCues, edits.ranges, guidance).filter((issue) => !issue.acknowledged), [captionCues, edits.ranges, guidance]); + const displayedSaveStatus = saveStatus.startsWith("Save failed") || saveStatus === "Saving…" ? saveStatus : savedSnapshot && savedSnapshot.id === identity?.id && savedSnapshot.revision === revision && savedSnapshot.output === result && savedSnapshot.mediaAssets===assemblyMedia ? "Saved in this browser" : metadata ? "Changes waiting to save…" : saveStatus; + const checkModelCache = useCallback(async () => { + try { + if (!("caches" in window)) { setModelCache("Model caching is unavailable in this browser."); return; } + const names = await caches.keys(); + if (!names.includes("transformers-cache")) { setModelCache("English model is not downloaded yet."); return; } + const cache = await caches.open("transformers-cache"), keys = await cache.keys(); + const urls = keys.map((key) => key.url).filter((url) => url.includes("onnx-community/whisper-tiny.en")); + setModelCache(urls.some((url) => url.includes("encoder_model_quantized.onnx")) && urls.some((url) => url.includes("decoder_model_merged_quantized.onnx")) ? "English model weights are cached on this device." : urls.length ? "Some English model files are cached; remaining files download on use." : "English model is not downloaded yet."); + } catch { setModelCache("Could not inspect the local model cache; valid cached files are still reused."); } + }, []); + // eslint-disable-next-line react-hooks/set-state-in-effect -- Detect browser capabilities after hydration. + useEffect(() => { setPipSupported(Boolean(document.pictureInPictureEnabled)); void checkModelCache(); }, [checkModelCache]); + const statusCopy = status === "PROCESSING" ? "Exporting on your device" : status === "FAILED" ? "Export needs attention" : result ? result.revision === revision ? "Latest export ready" : "Earlier export available" : null; + const edit = useCallback((value: EditDecisions, action: string, group?: string) => dispatch({ type: "edit", value, action, group }), []); + const refreshHistory = useCallback(async () => { + try { const projects = await listSavedProjects(); if (mounted.current) setRecentProjects(projects); } + catch (error) { if (mounted.current) setHistoryMessage(error instanceof Error ? error.message : "Could not load saved projects."); } + }, [setRecentProjects,setHistoryMessage]); + const cancelOperation = useCallback(() => { + generation.current += 1; + operation.current?.controller.abort(); operation.current = null;storageOperation.current?.abort();setClipOutputs(previous=>previous.map(o=>o.status==="waiting"||o.status==="working"?{...o,status:"cancelled",message:"Queue stopped; completed outputs remain available"}:o)); + setStatus("IDLE"); setProjectWorking(false); setProjectProgress(""); setCaptionWorking(false); setSceneScanStatus((value) => value === "working" ? "waiting" : value); + setSceneScanMessage(""); setProgress("Cancelled. Your edit and previous export are preserved."); + setSteps((previous) => Object.fromEntries(Object.entries(previous).map(([key, value]) => [key, value === "working" ? "waiting" : value])) as Steps); + }, [setClipOutputs,setStatus,setCaptionWorking,setSceneScanStatus,setSceneScanMessage,setProgress,setSteps,setProjectWorking,setProjectProgress]); + const beginOperation = () => { + operation.current?.controller.abort(); + setProcessingElapsed(0); + const job = { id: ++generation.current, controller: new AbortController() }; + operation.current = job; return job; + }; + const isCurrent = (job: { id: number; controller: AbortController }) => mounted.current && generation.current === job.id && !job.controller.signal.aborted; + const buildSavedProject = useCallback((): SavedProject | null => { + if (!file || !identity || !metadata || !edits.ranges.length) return null; + return { id: identity.id, sourceBytes:file.size,outputBytes:result?.videoBlob.size,title:edits.projectTitle?.trim()||file.name, fileName: file.name, createdAt: identity.createdAt, updatedAt: Date.now(), schemaVersion: 2, + sourceVideo: file, sourceIsExport: !identity.sourceAvailable, sourceDuration: duration, + images:edits.images,titles:edits.titles,assembly:edits.assembly,mediaAssets:assemblyMedia,clips:edits.clips,framing:edits.framing,audioAdjustments:edits.audioAdjustments,ranges:edits.ranges, trimStart:edits.ranges[0].start, trimEnd:edits.ranges[0].end, rangeId: edits.ranges[0].id, captions: captionCues, captionGuidance: guidance, captionStyle, captionDelivery, exportPreferences, revision, video: result?.videoBlob, exportRevision: result?.revision, exportId: result?.artifactId, exportSettings: result?.settings, exportMetadata:result?.metadata, + exportTrimStart: result?.trimStart, exportTrimEnd: result?.trimEnd, + exportCaptionsVtt: result?.captionsVtt ?? null, captionError: result?.captionError, + includeSceneDetection, sceneDetectionSucceeded: scenesDetected, + captionsVtt: captionCues.length ? serializeCaptions(captionCues) : null, captionTimebase: "source", + sceneCuts, sceneAnalysis: edits.sceneAnalysis, sceneSettings, dismissedCuts: edits.dismissedCuts, editorialMarkers, + sceneMarkers: sceneTimes, markerTimebase: "source", duration: result?.duration ?? sequenceLength }; + }, [file, identity, metadata, assemblyMedia,edits.images,edits.titles,edits.assembly,edits.clips,edits.framing,edits.audioAdjustments,edits.projectTitle, edits.ranges, sequenceLength,duration, revision, result, includeSceneDetection, scenesDetected, captionCues, sceneTimes, guidance, sceneCuts, edits.sceneAnalysis, sceneSettings, edits.dismissedCuts, editorialMarkers, captionStyle, captionDelivery, exportPreferences]); + const persist = useCallback(async () => { + const project = buildSavedProject(); + if (!project) return; + setSaveStatus("Saving…"); + const pending = saveQueue.current.catch(() => {}).then(() => saveProject(project)); + saveQueue.current = pending; + try { + await pending; + if (mounted.current) { setSavedSnapshot({ id: project.id, revision: project.revision ?? 0, output: result,mediaAssets:assemblyMedia }); setSaveStatus("Saved in this browser"); setHistoryMessage(""); await refreshHistory(); } + } catch (error) { + if (mounted.current) { setSaveStatus("Save failed — work remains in this tab"); setHistoryMessage(error instanceof Error ? error.message : "Local saving failed. Keep this tab open, download the source and export, then free browser storage and retry."); } + throw error; + } + }, [setSaveStatus,setSavedSnapshot,setHistoryMessage,assemblyMedia,buildSavedProject, refreshHistory, result]); + useEffect(() => { persistRef.current = persist; }, [persist]); + useEffect(() => { + const timer = window.setTimeout(() => { void persist().catch(() => {}); }, 600); + return () => window.clearTimeout(timer); + }, [persist, saveAttempt]); useEffect(() => { let active = true; - void listSavedProjects() - .then((projects) => { if (active) setRecentProjects(projects); }) - .catch((error: unknown) => { if (active) setHistoryMessage(error instanceof Error ? error.message : "Could not load recent projects."); }); + void listSavedProjects().then((projects) => { if (active) setRecentProjects(projects); }).catch((error: unknown) => { if (active) setHistoryMessage(error instanceof Error ? error.message : "Could not load saved projects."); }); return () => { active = false; }; }, []); - + useEffect(() => { + mounted.current = true; + const visibility = () => { if (document.visibilityState === "hidden") void persistRef.current?.().catch(() => {}); }; + document.addEventListener("visibilitychange", visibility); + return () => { mounted.current = false; generation.current++; operation.current?.controller.abort(); document.removeEventListener("visibilitychange", visibility); void persistRef.current?.().catch(() => {}); }; + }, []); + useEffect(() => () => { if (objectUrl) URL.revokeObjectURL(objectUrl); }, [objectUrl]); + useEffect(() => () => { if (result) URL.revokeObjectURL(result.videoUrl); }, [result]); useEffect(() => { const dialog = deleteDialogRef.current; if (!dialog) return; if (projectPendingDelete && !dialog.open) dialog.showModal(); else if (!projectPendingDelete && dialog.open) dialog.close(); }, [projectPendingDelete]); - useEffect(() => { - if (status !== "PROCESSING" || processingStartedAt.current === null) return; - const updateElapsed = () => setProcessingElapsed(Math.floor((Date.now() - (processingStartedAt.current ?? Date.now())) / 1000)); - updateElapsed(); - const timer = window.setInterval(updateElapsed, 1000); + if (!busy) return; + const started = Date.now(); + const timer = window.setInterval(() => setProcessingElapsed(Math.floor((Date.now() - started) / 1000)), 1000); return () => window.clearInterval(timer); - }, [status]); - - const clearResult = useCallback(() => { - if (resultRef.current) { - URL.revokeObjectURL(resultRef.current.videoUrl); - if (resultRef.current.captionsUrl) URL.revokeObjectURL(resultRef.current.captionsUrl); - } - resultRef.current = null; - setResult(null); - setCaptionCues([]); - }, []); - - useEffect(() => () => { if (objectUrl) URL.revokeObjectURL(objectUrl); }, [objectUrl]); - useEffect(() => () => { - if (resultRef.current) { - URL.revokeObjectURL(resultRef.current.videoUrl); - if (resultRef.current.captionsUrl) URL.revokeObjectURL(resultRef.current.captionsUrl); - } - }, []); - - const refreshHistory = useCallback(async () => { + }, [busy]); + useEffect(() => { + const video = videoRef.current; + if (video) { video.playbackRate = playbackRate; video.volume = previewVolume; } + // Initial media element settings; changes are handled by the transport and native events. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [objectUrl]); + const handleFileSelected = async (selected: File) => { + cancelOperation(); cancelSeek(); reviewBounds.current = null; setPlaying(false); setReviewMode("source"); setLastContext(null); frameRequest.current++; + setFrameMessage(""); setCaptionScope("selection"); setSnapToCuts(false); setForceFFmpeg(false); setSceneScanStatus("waiting"); setStepMessages({}); + const id = crypto.randomUUID(); + setIdentity({ id, createdAt: Date.now(), sourceAvailable: true }); + setAssemblyMedia({});setClipOutputs([]);setSequencePreview(null);setSelectedRange(0);setFile(selected); setObjectUrl(URL.createObjectURL(selected)); setMetadata(null); setResult(null); + dispatch({ type: "reset", value: { ranges: [], captions: [], sceneMarkers: [], scenesDetected: false, includeSceneDetection: false } }); + setCurrentTime(0); setErrorMessage(null); setHistoryMessage(""); setSaveStatus("Checking source…"); + setProgress("Checking the video container and tracks…"); + const importGeneration = generation.current; try { - setRecentProjects(await listSavedProjects()); - setHistoryMessage(""); + const source = await probeMedia(selected); + if (!mounted.current || generation.current !== importGeneration) return; + setMetadata(source); + dispatch({ type: "reset", value: { ranges: [{ id: crypto.randomUUID(), sourceId: id, start: 0, end: source.duration }], captions: [], sceneMarkers: [], scenesDetected: false, includeSceneDetection: false } }); + setSteps({ upload: "complete", trimming: "waiting", sceneDetection: "skipped", captions: "skipped" }); + setProgress("Source ready. The out point is excluded from the selection."); } catch (error) { - setHistoryMessage(error instanceof Error ? error.message : "Could not load recent projects."); + if (generation.current === importGeneration) { setErrorMessage(error instanceof Error ? error.message : "This video could not be read. Choose a supported video."); setSaveStatus("Source not validated"); setProgress(""); } } - }, []); - - const updateCaptionCues = useCallback((nextCues: CaptionCue[]) => { - setCaptionCues(nextCues); - const current = resultRef.current; - if (!current) return; - const vtt = serializeCaptions(nextCues); - const captionsUrl = nextCues.length ? URL.createObjectURL(new Blob([vtt], { type: "text/vtt" })) : undefined; - if (current.captionsUrl) URL.revokeObjectURL(current.captionsUrl); - const nextResult = { ...current, captionsUrl, captionError: undefined }; - resultRef.current = nextResult; - setResult(nextResult); - void saveProject({ - id: current.historyId, - fileName: current.fileName, - createdAt: current.createdAt, - video: current.videoBlob, - sourceVideo: current.sourceVideo, - sourceIsExport: !current.sourceAvailable, - sourceDuration: current.sourceDuration, - trimStart: current.trimStart, - trimEnd: current.trimEnd, - includeSceneDetection: current.includeSceneDetection, - includeCaptions: current.includeCaptions, - sceneDetectionSucceeded: current.sceneDetectionSucceeded, - captionsVtt: nextCues.length ? vtt : null, - sceneMarkers: current.sceneMarkers, - duration: current.duration, - }).then(refreshHistory).catch((error: unknown) => { - setHistoryMessage(error instanceof Error ? error.message : "Could not save caption changes to recent projects."); - }); - }, [refreshHistory]); - - const handleFileSelected = useCallback((selected: File) => { - projectIdentity.current = null; - pendingEditRange.current = null; - setFile(selected); - clearResult(); - setErrorMessage(null); - setStatus("IDLE"); - setProgress(""); - setDuration(0); - setCurrentTime(0); - setTrimStart(0); - setTrimEnd(0); - setSteps({ upload: "complete", trimming: "waiting", sceneDetection: "waiting", captions: "waiting" }); - setStepMessages({ upload: "Clip loaded" }); - setSceneTimes([]); - setScenesDetected(false); - setSceneScanStatus("waiting"); - setSceneScanMessage(""); - setSnapToCuts(false); - setIncludeSceneDetection(false); - setIncludeCaptions(false); - setHistoryMessage(""); - setObjectUrl((previous) => { - if (previous) URL.revokeObjectURL(previous); - return URL.createObjectURL(selected); - }); - }, [clearResult]); - - const handleProcess = useCallback(async () => { - if (!file) return; - clearResult(); - setStatus("PROCESSING"); - processingStartedAt.current = Date.now(); - setProcessingElapsed(0); - setProgress("Starting local processing…"); - setSteps({ upload: "complete", trimming: "working", sceneDetection: includeSceneDetection ? "waiting" : "skipped", captions: includeCaptions ? "waiting" : "skipped" }); - setStepMessages({ upload: "Clip loaded" }); - setErrorMessage(null); + }; + const updateCaptionCues = useCallback((nextCues: CaptionCue[], group?: string, action = "Edit captions") => edit({ ...edits, captions: nextCues }, action, group), [edit, edits]); + const updateGuidance = useCallback((captionGuidance: CaptionGuidance) => edit({ ...edits, captionGuidance }, "Change caption guidance", "caption-guidance"), [edit, edits]); + const updateCaptionStyle = useCallback((style:CaptionStyle,action:string,group?:string)=>edit({...edits,captionStyle:style},action,group),[edit,edits]); + const onCaptionPreviewError = useCallback((message:string)=>setFrameMessage(previous=>message?"Caption preview: "+message:previous.startsWith("Caption preview:")?"":previous),[setFrameMessage]); + const onImagePreviewError=useCallback((message:string)=>setFrameMessage(previous=>message?"Image preview: "+message:previous.startsWith("Image preview:")?"":previous),[setFrameMessage]); + const getPlayhead = useCallback(() => videoRef.current?.currentTime ?? 0, []); + const handleProcess = async (previewOnly=false) => { + if (!file || !metadata || !spec) return; + if(edits.assembly?.enabled){await exportAssembly(previewOnly);return;} + if (metadata.hdr && (edits.ranges.length!==1 || trimStart !== 0 || trimEnd !== duration || forceFFmpeg || burnCaptions || outputTitles.length>0 || outputImageRender.length>0 || spec.reencode)) { setErrorMessage("HDR re-encoding is unavailable until color-preserving rendering is verified. Choose Match source, subtitle files and the unchanged full range to preserve the original HDR file."); return; } + if(outputImageRender.some(image=>!image.blob)){setErrorMessage("Relink missing images before rendering.");return;} + const invalid = validateSequence(edits.ranges,identity?.id??"",duration,metadata?.frameRate); + if (invalid) { setErrorMessage(invalid); return; } + if(burnCaptions && !outputCues.length){setErrorMessage("There are no deliverable captions in the selection. Add or import captions, or choose subtitle-file delivery.");return;} + const job = beginOperation(), snapshotRevision = revision; + setStatus("PROCESSING"); setErrorMessage(null); setProgress("Preparing export…"); + setSteps({ upload: "complete", trimming: "working", sceneDetection: "skipped", captions: "skipped" }); + setStepMessages({ upload: "Source ready", captions: burnCaptions?"Edited captions are rendered with the video; transcription is not rerun":"Edited captions are delivered as subtitle files; transcription is not rerun" }); try { - const processed = await processLocally(file, trimStart, trimEnd, (phase: ProcessingPhase, phaseStatus: ProcessingPhaseStatus, message: string) => { - const displayStatus: StepStatus = phaseStatus; - setSteps((previous) => ({ ...previous, upload: "complete", [phase]: displayStatus })); - setStepMessages((previous) => ({ ...previous, [phase]: message })); - setProgress(message); - }, { sceneDetection: includeSceneDetection, captions: includeCaptions, sourceDuration: duration }, scenesDetected ? sceneTimes : undefined); - const cues = processed.captionError ? [] : parseCaptions(await processed.captions.text()); - setCaptionCues(cues); - const createdAt = Date.now(); - const historyId = projectIdentity.current?.id ?? `${createdAt}-${Math.random().toString(36).slice(2, 8)}`; - const sourceAvailable = projectIdentity.current?.sourceAvailable ?? true; - const next: Result = { - videoUrl: URL.createObjectURL(processed.video), - captionsUrl: cues.length ? URL.createObjectURL(new Blob([serializeCaptions(cues)], { type: "text/vtt" })) : undefined, - captionError: processed.captionError, - sceneMarkers: processed.sceneMarkers, - videoBlob: processed.video, - historyId, - createdAt, - fileName: file.name, - duration: trimEnd - trimStart, - sourceVideo: file, - sourceDuration: duration, - trimStart, - trimEnd, - includeSceneDetection, - includeCaptions, - sceneDetectionSucceeded: includeSceneDetection && !processed.sceneDetectionError, - sourceAvailable, - }; - projectIdentity.current = { id: historyId, sourceAvailable }; - resultRef.current = next; - setResult(next); - setStatus("COMPLETE"); - processingStartedAt.current = null; - if (includeSceneDetection && !scenesDetected && !processed.sceneDetectionError) { - setSceneTimes(processed.sceneMarkers.map((time) => Number((time + trimStart).toFixed(3)))); - setScenesDetected(true); - } else if (processed.sceneDetectionError) { - setScenesDetected(false); - } else if (!includeSceneDetection) { - setSceneTimes([]); - setScenesDetected(false); - } - const finalMessage = processed.captionError ? "Video export is ready." : "Export ready."; - setProgress(finalMessage); - setStepMessages((previous) => ({ ...previous, upload: "Clip loaded", trimming: "Trimmed video ready", sceneDetection: includeSceneDetection ? processed.sceneDetectionError ?? `${processed.sceneMarkers.length} cuts found` : "Skipped for a faster export", captions: includeCaptions ? processed.captionError ?? `${cues.length} cues created` : "Skipped for a faster export" })); - const savedProject: SavedProject = { - id: historyId, - fileName: file.name, - createdAt, - video: processed.video, - sourceVideo: file, - sourceIsExport: !sourceAvailable, - sourceDuration: duration, - trimStart, - trimEnd, - includeSceneDetection, - includeCaptions, - sceneDetectionSucceeded: includeSceneDetection && !processed.sceneDetectionError, - captionsVtt: cues.length ? serializeCaptions(cues) : null, - captionError: processed.captionError, - sceneMarkers: processed.sceneMarkers, - duration: trimEnd - trimStart, - }; - try { - await saveProject(savedProject); - await refreshHistory(); - } catch (error) { - try { - await saveProject({ ...savedProject, sourceVideo: undefined, sourceDuration: undefined, trimStart: undefined, trimEnd: undefined, sourceIsExport: true }); - await refreshHistory(); - setHistoryMessage("Browser storage could not keep the original clip. This project can still be reopened and edited from its exported MP4."); - } catch { - setHistoryMessage(error instanceof Error ? error.message : "Could not save this result in recent projects."); - } - } + const processed = await processLocally(file, trimStart, trimEnd, (phase, phaseStatus, message) => { + if (!isCurrent(job)) return; + setSteps((previous) => ({ ...previous, [phase]: phase === "trimming" && phaseStatus === "complete" ? "working" : phaseStatus })); + setStepMessages((previous) => ({ ...previous, [phase]: message })); setProgress(message); + }, { sceneDetection: false, captions: false, sourceDuration: duration, ranges:edits.ranges,imageRender:outputImageRender,titleRender:outputTitles.length?outputTitles:undefined, outputSpec:spec, signal: job.controller.signal, forceFFmpeg, captionRender:burnCaptions?{cues:outputCues,style:captionStyle}:undefined }, scenesDetected ? sceneTimes : undefined); + if (!isCurrent(job)) return; + setProgress("Checking exported tracks, duration and playback…"); + setSteps((previous) => ({ ...previous, trimming: "working" })); + setStepMessages((previous) => ({ ...previous, trimming: "Validating the exported file…" })); + const measured = await validateOutput(processed.video, {...metadata,width:spec.width,height:spec.height,hasAudio:spec.audio}, sequenceLength, job.controller.signal); + if (!isCurrent(job)) return; + if(previewOnly){setSequencePreview({url:URL.createObjectURL(processed.video),revision:snapshotRevision,captions:!burnCaptions&&outputCues.length?serializeCaptions(outputCues):null});setStatus("IDLE");setProgress("Rendered sequence preview ready. It uses the current export settings.");return;} + setResult({ videoUrl: URL.createObjectURL(processed.video), videoBlob: processed.video, artifactId: crypto.randomUUID(), settings: {images:outputImageRender.map(({blob:_blob,...image})=>{void _blob;return image;}),titles:outputTitles,framing,audioAdjustments,fileName:outputFileName(outputStem,exportPreferences), width: spec.width, height: spec.height, audio: spec.audio, preferences:exportPreferences, timing: "source", captions: captionDelivery, captionStyle:burnCaptions?captionStyle:undefined, processor: forceFFmpeg ? "ffmpeg" : "automatic" }, sceneMarkers: sceneTimes.flatMap(time=>sourceOccurrences(time,identity?.id??"",edits.ranges).map(hit=>hit.sequenceTime)), + revision: snapshotRevision, trimStart, trimEnd, duration: measured.duration, metadata: measured, + captionsVtt: outputCues.length ? serializeCaptions(outputCues) : null }); + setStatus("COMPLETE"); setSteps((previous) => ({ ...previous, trimming: "complete" })); + setProgress("Export ready. Continue editing the source; this file stays available until the next export succeeds."); } catch (error) { - setStatus("FAILED"); - processingStartedAt.current = null; - setErrorMessage(error instanceof Error ? error.message : "Unable to process this video in the browser."); - setProgress(""); - setSteps((previous) => { - const active = (Object.keys(previous) as (keyof Steps)[]).find((key) => previous[key] === "working"); - return active ? { ...previous, [active]: "failed" } : previous; - }); - } - }, [file, trimStart, trimEnd, duration, clearResult, scenesDetected, sceneTimes, includeSceneDetection, includeCaptions, refreshHistory]); - - const detectSceneCuts = useCallback(async () => { - if (!file) return; - setIncludeSceneDetection(true); - setSceneScanStatus("working"); - setSteps((previous) => ({ ...previous, sceneDetection: "working" })); - setSceneScanMessage("Preparing scene scan…"); + if (!isCurrent(job)) return; + setStatus("FAILED"); setErrorMessage(error instanceof Error ? error.message : "Export failed. Try the compatible processor or a shorter selection."); + setSteps((previous) => ({ ...previous, trimming: "failed" })); + } finally { if (isCurrent(job)) operation.current = null; } + }; + const updateSceneCuts = (cuts:SceneCut[],action:string,removed?:SceneCut) => { + const sorted = [...cuts].sort((a,b)=>a.time-b.time); + edit({ ...edits, sceneCuts:sorted, sceneMarkers:sorted.map(cut=>cut.time), scenesDetected:true, + dismissedCuts:removed?[...(edits.dismissedCuts??[]),removed.bracket??{start:removed.time-.15,end:removed.time+.15}]:edits.dismissedCuts },action); + }; + const overlayStart=async()=>{const time=Math.max(0,Math.min(videoRef.current?.currentTime??currentTime,duration));return time>=duration-.000001&&file?(await lastIncludedFrame(file,duration)??Math.max(0,duration-(metadata?.frameRate?1/metadata.frameRate:.000001))):time;}; + const addTitle=async()=>{if(busy||!metadata||(edits.titles?.length??0)>=10)return;const job=beginOperation();setProjectWorking(true);setProjectProgress("Locating the displayed source frame…");try{const start=await overlayStart();if(!isCurrent(job))return;edit({...edits,titles:[...(edits.titles??[]),{id:Math.max(0,...(edits.titles??[]).map(title=>title.id))+1,start,end:Math.min(duration,start+3),text:"Your title",style:{...DEFAULT_CAPTION_STYLE,size:7,bold:true,position:"top"}}]},"Add title");setProjectProgress("");}catch(error){if(isCurrent(job))setErrorMessage(error instanceof Error?error.message:"Title could not be added.");}finally{if(isCurrent(job)){operation.current=null;setProjectWorking(false);}}}; + const addImage=async(selected:File)=>{ + if(busy||!metadata)return;const job=beginOperation();setProjectWorking(true);setProjectProgress("Decoding local image…");setErrorMessage(null); + try{ + const start=await overlayStart(); + if((edits.images?.length??0)>=5)throw new Error("Use no more than five image overlays."); + if(!selected.size||selected.size>2*1024*1024)throw new Error("Use an image up to 2 MB."); + const mime=await imageMime(selected); + if(!mime)throw new Error("Choose an actual PNG, JPEG or WebP image."); + const image=new File([selected],selected.name,{type:mime}),bitmap=await createImageBitmap(image);let dimensions:{width:number;height:number}; + try{if(bitmap.width>4096||bitmap.height>4096||bitmap.width*bitmap.height>8_388_608)throw new Error("Use an image up to 4096 pixels per edge and 8 megapixels.");dimensions={width:bitmap.width,height:bitmap.height};}finally{bitmap.close();} + const fingerprint=await fingerprintSource(image,job.controller.signal);if(!isCurrent(job))return; + const overlay:ImageOverlay={id:crypto.randomUUID(),assetId:"image:"+fingerprint,start,end:Math.min(duration,start+3),x:80,y:20,size:20,opacity:1,reference:{kind:"image",fileName:selected.name.slice(0,255),mime,bytes:image.size,fingerprint,algorithm:"sha256-chunks-4m-v1",metadata:dimensions}}; + setAssemblyMedia(previous=>({...previous,[fingerprint]:image}));edit({...edits,images:[...(edits.images??[]),overlay]},"Add image overlay");setProjectProgress("Local image verified and added."); + }catch(error){if(isCurrent(job))setErrorMessage(error instanceof Error?error.message:"Image could not be added.");} + finally{if(isCurrent(job)){operation.current=null;setProjectWorking(false);}} + }; + const relinkImage=async(image:ImageOverlay,selected:File)=>{ + if(busy)return;const job=beginOperation();setProjectWorking(true);setProjectProgress("Verifying image contents…");setErrorMessage(null); + try{await verifyImageRelink(image.reference,selected,job.controller.signal);if(isCurrent(job)){setAssemblyMedia(previous=>({...previous,[image.reference.fingerprint]:selected}));setSaveAttempt(value=>value+1);setProjectProgress("Image contents match; the layer is ready to render.");}} + catch(error){if(isCurrent(job))setErrorMessage(error instanceof Error?error.message:"Image does not match.");} + finally{if(isCurrent(job)){operation.current=null;setProjectWorking(false);}} + }; + const addAssemblySource=async(selected:File,primary=false)=>{ + if(busy||!identity)return; + const job=beginOperation();setProjectWorking(true);setErrorMessage(null);setProjectProgress("Reading assembly source metadata…"); + try{ + const source=await probeMedia(selected,job.controller.signal); + const fingerprint=await fingerprintSource(selected,job.controller.signal,progress=>{if(isCurrent(job))setProjectProgress("Verifying local source… "+Math.round(progress*100)+"%");}); + if(!isCurrent(job))return; + const previous=edits.assembly,existing=previous?.sources.find(s=>s.reference.fingerprint===fingerprint&&Boolean(s.primary)===primary),physical=previous?.sources.find(s=>s.reference.fingerprint===fingerprint); + if(!existing&&(previous?.sources.length??0)>=4)throw new Error("Use no more than four assembly sources."); + const id=existing?.id??crypto.randomUUID(); + const reference={fileName:selected.name.slice(0,255),mime:selected.type,bytes:selected.size,fingerprint,algorithm:"sha256-chunks-4m-v1" as const,kind:primary&&!identity.sourceAvailable?"saved-export" as const:"original" as const,metadata:source}; + const assetId=existing?.assetId??physical?.assetId??(primary?identity.sourceAssetId??identity.id:"media:"+fingerprint); + const sources=existing?previous!.sources:[...(previous?.sources??[]),{primary,id,assetId,reference,captions:primary?structuredClone(captionCues):[],images:primary?structuredClone(edits.images??[]):[],titles:primary?structuredClone(edits.titles??[]):[],markers:primary?structuredClone(editorialMarkers):[],sceneMarkers:primary?[...sceneTimes]:[]}]; + const ranges=[...(previous?.ranges??[]),{id:crypto.randomUUID(),sourceId:id,start:primary?trimStart:0,end:primary?trimEnd:source.duration}]; + if(ranges.length>100)throw new Error("Keep no more than 100 assembly ranges."); + setAssemblyMedia(previous=>({...previous,[fingerprint]:selected})); + edit({...edits,assembly:{enabled:true,sources,ranges}},"Add assembly source"); + setProjectProgress("Source verified and added. Assembly export is now enabled."); + }catch(error){if(isCurrent(job))setErrorMessage(error instanceof Error?error.message:"Source could not be added.");} + finally{if(isCurrent(job)){operation.current=null;setProjectWorking(false);}} + }; + const relinkAssemblySource=async(source:AssemblySource,selected:File)=>{ + if(busy)return;const job=beginOperation();setProjectWorking(true);setProjectProgress("Verifying missing source…");setErrorMessage(null); + try{const actual=await probeMedia(selected,job.controller.signal);await verifyRelink({source:source.reference} as ProjectBackup,selected,actual,job.controller.signal);if(isCurrent(job)){setAssemblyMedia(previous=>({...previous,[source.reference.fingerprint]:selected}));setSaveAttempt(value=>value+1);setProjectProgress("Source matches and is ready to render.");}} + catch(error){if(isCurrent(job))setErrorMessage(error instanceof Error?error.message:"Source does not match.");} + finally{if(isCurrent(job)){operation.current=null;setProjectWorking(false);}} + }; + const exportAssembly=async(previewOnly=false)=>{ + if(!edits.assembly||!metadata||!spec||busy)return;if(outputImageRender.some(image=>!image.blob)){setErrorMessage("Relink missing assembly images before rendering.");return;}const error=validateAssembly(edits.assembly);if(error){setErrorMessage(error);return;} + if(burnCaptions&&!outputCues.length){setErrorMessage("No deliverable captions in the assembly. Add source subtitles or choose subtitle-file delivery.");return;} + const job=beginOperation(),snapshotRevision=revision;setStatus("PROCESSING");setErrorMessage(null);setProgress("Preparing multiple-source assembly…");setSteps({upload:"complete",trimming:"working",sceneDetection:"skipped",captions:"skipped"}); + try{ + const blob=await processAssembly(structuredClone(edits.assembly),assemblyMedia,spec,message=>{if(isCurrent(job)){setProgress(message);setStepMessages(previous=>({...previous,trimming:message}));}},job.controller.signal,burnCaptions?{cues:outputCues,style:captionStyle}:undefined,outputTitles.length?outputTitles:undefined,outputImageRender,forceFFmpeg); + const measured=await validateOutput(blob,{...metadata,width:spec.width,height:spec.height,hasAudio:spec.audio},sequenceLength,job.controller.signal);if(!isCurrent(job))return; + const captions=outputCues.length?serializeCaptions(outputCues):null; + if(previewOnly)setSequencePreview({url:URL.createObjectURL(blob),revision:snapshotRevision,captions:burnCaptions?null:captions}); + else setResult({videoUrl:URL.createObjectURL(blob),videoBlob:blob,artifactId:crypto.randomUUID(),revision:snapshotRevision,trimStart:0,trimEnd:sequenceLength,duration:measured.duration,metadata:measured,captionsVtt:captions,sceneMarkers:assemblyMarkers(edits.assembly).cuts,settings:{images:outputImageRender.map(({blob:_blob,...image})=>{void _blob;return image;}),titles:outputTitles,framing,audioAdjustments,width:spec.width,height:spec.height,audio:spec.audio,preferences:exportPreferences,timing:"source",captions:captionDelivery,captionStyle:burnCaptions?captionStyle:undefined,fileName:outputFileName(outputStem,exportPreferences),processor:forceFFmpeg?"ffmpeg":"automatic"}}); + setStatus(previewOnly?"IDLE":"COMPLETE");setSteps(previous=>({...previous,trimming:"complete"}));setProgress(previewOnly?"Assembly preview verified.":"Assembly export verified; original sources and decisions remain editable."); + }catch(error){if(isCurrent(job)){setStatus("FAILED");setErrorMessage(error instanceof Error?error.message:"Assembly export failed; previous files are preserved.");setSteps(previous=>({...previous,trimming:"failed"}));}} + finally{if(isCurrent(job))operation.current=null;} + }; + const captureFrame=async(composed:boolean)=>{ + if(!file||!metadata||!spec||busy)return; + const job=beginOperation();setProjectWorking(true);setProjectProgress("Decoding the selected source frame…"); + let input:import("mediabunny").Input|null=null; + try{ + const {Input,BlobSource,ALL_FORMATS,CanvasSink}=await import("mediabunny"); + const {drawFramed}=await import("@/lib/adjustments");const {drawTitles}=await import("@/lib/title-tools");const {drawCaptions,loadCaptionFont}=await import("@/lib/caption-renderer"); + input=new Input({source:new BlobSource(file),formats:ALL_FORMATS});const abort=()=>input?.dispose();job.controller.signal.addEventListener("abort",abort,{once:true}); + try{ + const track=await input.getPrimaryVideoTrack();if(!track||!await track.canDecode())throw new Error("Source frame capture is unavailable for this codec."); + const frame=await new CanvasSink(track,{poolSize:1}).getCanvas(videoRef.current?.currentTime??currentTime);if(!frame)throw new Error("No source frame is available at this time."); + job.controller.signal.throwIfAborted();const canvas=document.createElement("canvas");canvas.width=composed?spec.width:frame.canvas.width;canvas.height=composed?spec.height:frame.canvas.height;const ctx=canvas.getContext("2d")!; + if(composed){drawFramed(ctx,(context,x,y,w,h)=>context.drawImage(frame.canvas,x,y,w,h),frame.canvas.width,frame.canvas.height,canvas.width,canvas.height,framing);const imageLayers=await loadImageLayers(primaryImageRender);try{drawImageLayers(ctx,imageLayers,frame.timestamp,canvas.width,canvas.height);}finally{closeImageLayers(imageLayers);}await loadCaptionFont(captionStyle.font);for(const title of edits.titles??[])await loadCaptionFont(title.style.font);drawTitles(ctx,edits.titles??[],frame.timestamp,canvas.width,canvas.height);drawCaptions(ctx,captionCues,frame.timestamp,canvas.width,canvas.height,captionStyle);}else ctx.drawImage(frame.canvas,0,0); + const blob=await new Promise((resolve,reject)=>canvas.toBlob(blob=>blob?resolve(blob):reject(new Error("The browser could not encode this image.")),"image/png")); + if(!isCurrent(job))return;const url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download=outputStem+"-"+(composed?"composed":"source")+"-"+frame.timestamp.toFixed(3)+".png";a.click();setTimeout(()=>URL.revokeObjectURL(url),30000);setProjectProgress("Frame download started at decoded source time "+formatPreviewTime(frame.timestamp)+"."); + }finally{job.controller.signal.removeEventListener("abort",abort);} + }catch(error){if(isCurrent(job))setErrorMessage(error instanceof Error?error.message:"Frame capture failed.");} + finally{input?.dispose();if(isCurrent(job)){operation.current=null;setProjectWorking(false);}} + }; + const exportClips=async()=>{ + if(!file||!metadata||busy)return; + const clips=structuredClone(edits.clips?.filter(c=>c.selected)??[]),snapshot=structuredClone(edits),snapshotRevision=revision; + if(!clips.length)return; + const job=beginOperation();setProjectWorking(true);setErrorMessage(null); + setClipOutputs(previous=>[...previous.filter(o=>!clips.some(c=>c.id===o.id)),...clips.map(c=>({...previous.find(o=>o.id===c.id),id:c.id,status:"waiting" as const,message:"Captured for export",revision:snapshotRevision}))]); + const update=(id:string,value:Partial)=>{if(isCurrent(job))setClipOutputs(previous=>previous.map(o=>o.id===id?{...o,...value}:o));}; + try{for(const clip of clips){ + if(!isCurrent(job))break; + const controller=new AbortController();clipJobRef.current=controller; + const abort=()=>controller.abort();job.controller.signal.addEventListener("abort",abort,{once:true}); + update(clip.id,{status:"working",message:"Preparing clip"}); + try{ + const invalid=validateRange(clip.range.start,clip.range.end,duration,metadata.frameRate);if(invalid)throw new Error(invalid); + const clipSpec=outputSpec(metadata.width,metadata.height,metadata.hasAudio,clip.preferences,clip.framing,snapshot.audioAdjustments??DEFAULT_AUDIO); + if(metadata.hdr)throw new Error("Queued HDR re-encoding is unavailable; preserve the unchanged source instead."); + const cues=mapCuesToOutput(snapshot.captions,[clip.range]).filter(c=>c.text.trim()&&isSubtitleInterval(c.start,c.end)); + if(snapshot.captionDelivery!=="sidecar"&&snapshot.captionDelivery!==undefined&&!cues.length)throw new Error("No deliverable captions in this clip. Choose subtitle-file delivery or revise the range."); + const burn=snapshot.captionDelivery==="burned"||snapshot.captionDelivery==="both"; + const processed=await processLocally(file,clip.range.start,clip.range.end,(_phase,_status,message)=>{update(clip.id,{message});if(isCurrent(job))setProjectProgress(clip.name+": "+message);},{sourceDuration:duration,imageRender:bindImages(mapCuesToOutput(snapshot.images??[],[clip.range]),assemblyMedia),titleRender:mapCuesToOutput(snapshot.titles??[],[clip.range]),sceneDetection:false,captions:false,outputSpec:clipSpec,signal:controller.signal,forceFFmpeg,captionRender:burn?{cues,style:snapshot.captionStyle??DEFAULT_CAPTION_STYLE}:undefined}); + await validateOutput(processed.video,{...metadata,width:clipSpec.width,height:clipSpec.height,hasAudio:clipSpec.audio},clip.range.end-clip.range.start,controller.signal); + if(!isCurrent(job))break; + const old=clipUrlsRef.current.get(clip.id);if(old)URL.revokeObjectURL(old); + const url=URL.createObjectURL(processed.video);clipUrlsRef.current.set(clip.id,url); + update(clip.id,{status:"complete",exportRevision:snapshotRevision,message:"Playable output verified",url,fileName:outputFileName(projectFileStem(clip.name),clip.preferences),captions:cues.length?serializeCaptions(cues):undefined}); + }catch(error){update(clip.id,{status:controller.signal.aborted?"cancelled":"failed",message:error instanceof Error?error.message:"Clip export failed."});} + finally{job.controller.signal.removeEventListener("abort",abort);if(clipJobRef.current===controller)clipJobRef.current=null;} + }}finally{if(isCurrent(job)){operation.current=null;setProjectWorking(false);setProjectProgress("Clip queue finished. Review each result before delivery.");}} + }; + const updateEditorialMarkers = useCallback((markers:EditorialMarker[],action:string,group?:string) => edit({...edits,editorialMarkers:markers},action,group),[edit,edits]); + const detectSceneCuts = async (replace = false) => { + if (!file || !metadata || !identity) return; + if ((scenesDetected || edits.sceneAnalysis) && !window.confirm(replace ? "Replace all cuts and dismissed candidates in the scan area? Cuts outside the area and editorial notes remain. Undo can restore your reviewed cuts." : "Rescan this area? Unreviewed candidates will refresh; manual, confirmed and dismissed cuts will be preserved. Undo can restore the previous scan.")) return; + const job = beginOperation(); + const start = sceneSettings.scope === "source" ? 0 : trimStart, end = sceneSettings.scope === "source" ? duration : trimEnd; + setSceneScanStatus("working"); setSceneScanMessage("Preparing source scene scan…"); setErrorMessage(null); + setSteps({ upload:"complete",trimming:"skipped",sceneDetection:"working",captions:"skipped" }); try { - const detected = await detectScenesLocally(file, setSceneScanMessage); - setSceneTimes(detected); - setScenesDetected(true); - setSceneScanStatus("complete"); - setSceneScanMessage(`${detected.length} scene ${detected.length === 1 ? "cut" : "cuts"} found.`); - setSteps((previous) => ({ ...previous, sceneDetection: "complete" })); - setStepMessages((previous) => ({ ...previous, sceneDetection: `${detected.length} cuts found` })); - } catch (error) { - const message = error instanceof Error ? error.message : "Could not detect scene changes."; - setSceneScanStatus("failed"); - setSceneScanMessage(message); - setSteps((previous) => ({ ...previous, sceneDetection: "failed" })); - } - }, [file]); - - const restoreProject = useCallback((project: SavedProject) => { - clearResult(); - const sourceVideo = project.sourceVideo ?? project.video; - const sourceDuration = project.sourceDuration ?? project.duration; - const sceneDetectionSucceeded = project.sceneDetectionSucceeded ?? project.sceneMarkers.length > 0; - const includedSceneDetection = project.includeSceneDetection ?? project.sceneMarkers.length > 0; - const includedCaptions = project.includeCaptions ?? Boolean(project.captionsVtt || project.captionError); - const sourceFile = new File([sourceVideo], project.fileName, { type: project.sourceVideo ? sourceVideo.type : "video/mp4" }); - const sourceAvailable = Boolean(project.sourceVideo) && !project.sourceIsExport; - projectIdentity.current = { id: project.id, sourceAvailable }; - pendingEditRange.current = null; - setObjectUrl((previous) => { - if (previous) URL.revokeObjectURL(previous); - return URL.createObjectURL(sourceFile); - }); - const cues = project.captionsVtt ? parseCaptions(project.captionsVtt) : []; - setCaptionCues(cues); - const next: Result = { - videoUrl: URL.createObjectURL(project.video), - captionsUrl: cues.length ? URL.createObjectURL(new Blob([serializeCaptions(cues)], { type: "text/vtt" })) : undefined, - captionError: project.captionError, - sceneMarkers: project.sceneMarkers, - videoBlob: project.video, - historyId: project.id, - createdAt: project.createdAt, - fileName: project.fileName, - duration: project.duration, - sourceVideo, - sourceDuration, - trimStart: project.trimStart ?? 0, - trimEnd: project.trimEnd ?? sourceDuration, - includeSceneDetection: includedSceneDetection, - includeCaptions: includedCaptions, - sceneDetectionSucceeded, - sourceAvailable, - }; - resultRef.current = next; - setResult(next); - setFile(sourceFile); - setStatus("COMPLETE"); - setDuration(project.duration); - setTrimStart(0); - setTrimEnd(project.duration); - setCurrentTime(0); - setSceneTimes(project.sceneMarkers.map((time) => time + (project.trimStart ?? 0))); - setScenesDetected(sceneDetectionSucceeded); - setSceneScanStatus(sceneDetectionSucceeded ? "complete" : "waiting"); - setSceneScanMessage(`${project.sceneMarkers.length} scene cuts`); - setSteps({ upload: "complete", trimming: "complete", sceneDetection: includedSceneDetection ? sceneDetectionSucceeded ? "complete" : "failed" : "skipped", captions: project.captionError ? "failed" : includedCaptions ? "complete" : "skipped" }); - setStepMessages({ upload: "Restored from history", trimming: "Export ready", sceneDetection: `${project.sceneMarkers.length} cuts found`, captions: project.captionError ?? `${cues.length} cues` }); - setProgress("Opened from recent projects."); - setErrorMessage(null); - setHistoryMessage(""); - }, [clearResult]); - - const editVideo = useCallback(() => { - const current = resultRef.current; - if (!current) return; - pendingEditRange.current = { start: current.trimStart, end: current.trimEnd }; - setDuration(current.sourceDuration); - setTrimStart(current.trimStart); - setTrimEnd(current.trimEnd); - setCurrentTime(0); - setSceneTimes(current.sceneMarkers.map((time) => time + current.trimStart)); - setScenesDetected(current.sceneDetectionSucceeded); - setSceneScanStatus(current.sceneDetectionSucceeded ? "complete" : "waiting"); - setSceneScanMessage(""); - setIncludeSceneDetection(current.includeSceneDetection); - setIncludeCaptions(current.includeCaptions); - setSteps({ upload: "complete", trimming: "waiting", sceneDetection: current.includeSceneDetection ? "complete" : "waiting", captions: "waiting" }); - setStepMessages({ upload: "Clip loaded" }); - clearResult(); - setStatus("IDLE"); - setProgress(current.sourceAvailable ? "Ready to edit the original clip." : "Editing the saved export. The original clip was not stored with this older project."); - }, [clearResult]); - - const removeSavedProject = useCallback(async (project: SavedProject) => { - setDeletingProjectId(project.id); + const detected = await analyzeScenes(file,identity.id,duration,start,end,sceneSettings,message => {if(isCurrent(job)){setSceneScanMessage(message);setStepMessages(previous=>({...previous,sceneDetection:message}));}},job.controller.signal); + if (!isCurrent(job)) return; + const checkedEnd = detected.analysis.complete ? end : detected.analysis.coverageEnd; + const cuts = mergeSceneScan(sceneCuts,detected.cuts,start,checkedEnd,replace,edits.dismissedCuts); + edit({...edits,sceneMarkers:cuts.map(cut=>cut.time),sceneCuts:cuts,sceneAnalysis:detected.analysis,sceneSettings, + dismissedCuts:replace?(edits.dismissedCuts??[]).filter(area=>area.start=checkedEnd):edits.dismissedCuts, + scenesDetected:true,includeSceneDetection:true},"Detect scene cuts"); + setSteps(previous=>({...previous,sceneDetection:"complete"}));setSceneScanStatus("complete"); + setSceneScanMessage(detected.cuts.length+" candidates found; "+cuts.length+" cuts retained. "+(detected.analysis.complete?"Sampled scan area completed.":"Partial scan; check the coverage record.")+" Review candidates before relying on them."); + } catch(error) { + if(isCurrent(job)){setSteps(previous=>({...previous,sceneDetection:"failed"}));setSceneScanStatus("failed");const message=error instanceof Error?error.message:"Scene analysis failed; previous cuts are preserved.";setSceneScanMessage(message);setErrorMessage(message);} + } finally {if(isCurrent(job))operation.current=null;} + }; + const generateCaptions = async () => { + if (!file || !metadata) return; + if (!metadata.hasAudio) { setProgress("This source has no audio track. Trimming and video export remain available."); return; } + const captionStart = captionScope === "source" ? 0 : trimStart, captionEnd = captionScope === "source" ? duration : trimEnd; + if (captionCues.length && !window.confirm("Replace current captions with a new transcription of " + (captionScope === "source" ? "the full source" : "the selection") + "? Undo can recover your corrections. Cancel to keep them.")) return; + const job = beginOperation(); + setCaptionWorking(true); setErrorMessage(null); setProgress("Preparing captions…"); + setSteps({ upload: "complete", trimming: "skipped", sceneDetection: "skipped", captions: "working" }); try { - await deleteSavedProject(project.id); - setRecentProjects((projects) => projects.filter((item) => item.id !== project.id)); - setProjectPendingDelete(null); - setHistoryMessage(""); + const vtt = await transcribeLocally(file, captionStart, captionEnd, (message) => { + if (isCurrent(job)) { setProgress(message); setStepMessages((previous) => ({ ...previous, captions: message })); } + }, job.controller.signal); + if (!isCurrent(job)) return; + const cues = parseCaptions(vtt).map((cue) => ({ ...cue, start: cue.start + captionStart, end: cue.end + captionStart })); + edit({ ...edits, captions: cues }, "Generate captions"); + setSteps((previous) => ({ ...previous, captions: "complete" })); + setProgress(cues.length + " captions ready to correct. Video export is a separate action."); } catch (error) { - setHistoryMessage(error instanceof Error ? error.message : "Could not delete this project from browser history."); - } finally { - setDeletingProjectId(null); - } - }, []); - - const seekPreviewTo = useCallback((point: "beginning" | "left" | "right") => { - const video = videoRef.current; - if (!video) return; - selectedPreview.current = false; - const time = result - ? point === "right" ? Math.max(0, trimEnd - trimStart) : 0 - : point === "beginning" ? 0 : point === "left" ? trimStart : trimEnd; - video.currentTime = time; - }, [result, trimEnd, trimStart]); - - const jumpToCut = useCallback((direction: "previous" | "next") => { + if (isCurrent(job)) { + const message = error instanceof Error ? error.message : "Caption generation failed; your previous captions are preserved."; + if (error instanceof Error && error.name === "NoSpeechError") { setProgress(message + " Existing captions are preserved."); setSteps((previous) => ({ ...previous, captions: "skipped" })); } + else { setErrorMessage(message); setSteps((previous) => ({ ...previous, captions: "failed" })); } + } + } finally { if (isCurrent(job)) { setCaptionWorking(false); operation.current = null; void checkModelCache(); } } + }; + const restoreProject = async (summary: SavedProjectSummary) => { + cancelOperation(); cancelSeek(); reviewBounds.current = null; setPlaying(false); setReviewMode("source"); setLastContext(null); frameRequest.current++; + setFrameMessage(""); setSnapToCuts(false); setForceFFmpeg(false); + const ticket = generation.current; + try { + await saveQueue.current.catch(() => {}); + const project = await loadSavedProject(summary.id), sourceVideo = project.sourceVideo ?? project.video; + if (!sourceVideo) throw new Error("This project has no stored media."); + const sourceAvailable = Boolean(project.sourceVideo) && !project.sourceIsExport; + const source = await probeMedia(sourceVideo); + if (generation.current !== ticket || !mounted.current) return; + const sourceTimebase = sourceAvailable || project.captionTimebase === "source"; + const start = sourceTimebase ? project.trimStart ?? 0 : 0, end = sourceTimebase ? project.trimEnd ?? source.duration : source.duration; + const restoredRanges=project.ranges?.map(range=>({...range,sourceId:project.id}))??[{id:project.rangeId??project.id+"-range",sourceId:project.id,start,end}]; + const invalid = validateSequence(restoredRanges,project.id,source.duration,source.frameRate); + if (invalid) throw new Error("This saved selection is incompatible with its stored media. " + invalid); + const captionOffset = project.captionTimebase !== "source" && sourceAvailable ? project.exportTrimStart ?? project.trimStart ?? 0 : 0; + const markerOffset = project.markerTimebase !== "source" && sourceAvailable ? project.exportTrimStart ?? project.trimStart ?? 0 : 0; + const sourceFile = new File([sourceVideo], project.fileName, { type: sourceVideo.type }); + setAssemblyMedia(project.mediaAssets??{});setClipOutputs([]);setSequencePreview(null);setSelectedRange(0);setFile(sourceFile); setObjectUrl(URL.createObjectURL(sourceFile)); setMetadata(source); + setIdentity({ id: project.id,sourceAssetId:project.sourceAssetId??project.id, createdAt: project.createdAt, sourceAvailable }); + dispatch({ type: "reset", revision: project.revision ?? 0, value: { + images:project.images,titles:project.titles,assembly:project.assembly,clips:project.clips?.map(clip=>({...clip,range:{...clip.range,sourceId:project.id}})),framing:project.framing,audioAdjustments:project.audioAdjustments,projectTitle:project.title, + ranges:restoredRanges, + captions: project.captions ?? (project.captionsVtt ? parseCaptions(project.captionsVtt).map((cue) => ({ ...cue, start: cue.start + captionOffset, end: cue.end + captionOffset })) : []), + sceneMarkers: project.sceneMarkers.map((time) => time + markerOffset), + sceneCuts:project.sceneCuts,sceneAnalysis:project.sceneAnalysis,sceneSettings:project.sceneSettings, + dismissedCuts:project.dismissedCuts,editorialMarkers:project.editorialMarkers, + scenesDetected: project.sceneDetectionSucceeded ?? project.sceneMarkers.length > 0, + captionStyle:project.captionStyle,captionDelivery:project.captionDelivery,exportPreferences:project.exportPreferences, + captionGuidance: project.captionGuidance, includeSceneDetection: project.includeSceneDetection ?? false } }); + setResult(project.video ? { videoUrl: URL.createObjectURL(project.video), videoBlob: project.video, artifactId: project.exportId ?? project.id + "-legacy-export", settings: project.exportSettings ?? { width: source.width, height: source.height, audio: source.hasAudio, timing: "source", captions: "sidecar", processor: "automatic" }, sceneMarkers: [], + revision: project.exportRevision ?? 0, trimStart: project.exportTrimStart ?? start, trimEnd: project.exportTrimEnd ?? end, + captionsVtt: project.exportCaptionsVtt ?? null, duration: project.duration,metadata:project.exportMetadata } : null); + setCurrentTime(0); setStatus("IDLE"); setErrorMessage(null); setHistoryMessage(""); + setSaveStatus("Saved in this browser"); setSceneScanStatus("waiting"); setSceneScanMessage(""); + setSteps({ upload: "complete", trimming: "waiting", sceneDetection: "skipped", captions: "skipped" }); + setProgress(sourceAvailable ? "Restored source and editable decisions." : "This older project only retained an exported video. The original source cannot be recovered; edits use the saved export."); + } catch (error) { if (generation.current === ticket) setHistoryMessage(error instanceof Error ? error.message : "Could not reopen the saved project."); } + }; + const downloadProject = async (includeSource:boolean) => { + const snapshot=buildSavedProject();if(!snapshot||!file||!metadata)return; + const job=beginOperation();setProjectWorking(true);setProjectProgress("Preparing project backup…"); + try{ + const backup=await createBackup(snapshot,edits,metadata,file,job.controller.signal,progress=>{if(isCurrent(job))setProjectProgress("Verifying source contents… "+Math.round(progress*100)+"%");}); + if(!isCurrent(job))return; + const blob=backupBlob(backup,includeSource?file:undefined,assemblyMedia),url=URL.createObjectURL(blob),link=document.createElement("a"); + link.href=url;link.download=projectFileStem(backup.title)+(includeSource?".cutmark":".cutmark.json");document.body.append(link);link.click();link.remove();window.setTimeout(()=>URL.revokeObjectURL(url),30000); + setProjectProgress(includeSource?"Backup download started with source media. Previous rendered MP4s are separate downloads.":"Project file download started without media. Keep the matching source video for recovery."); + }catch(error){if(isCurrent(job))setProjectProgress(error instanceof Error?error.message:"Backup failed. The open project is preserved.");} + finally{if(isCurrent(job)){setProjectWorking(false);operation.current=null;}} + }; + const duplicateProject = async () => { + const snapshot=buildSavedProject();if(!snapshot)return;const job=beginOperation();setProjectWorking(true);setProjectProgress("Duplicating saved decisions…"); + try{await persist();if(!isCurrent(job))return;const duplicate=await duplicateSavedProject(snapshot.id);if(!isCurrent(job))return;await restoreProject(duplicate);await refreshHistory();} + catch(error){if(isCurrent(job))setProjectProgress(error instanceof Error?error.message:"Duplication failed. Your project is preserved.");} + finally{if(isCurrent(job)){setProjectWorking(false);operation.current=null;}} + }; + const recoverBackup = async (backup:ProjectBackup,source:Blob,mediaAssets:Record={}) => { + const job=beginOperation();setProjectWorking(true); + try{ + await persist();if(!isCurrent(job))throw new Error("Recovery cancelled; the open project is preserved."); + const project=restoredProject(backup,source,mediaAssets),pending=saveQueue.current.catch(()=>{}).then(()=>{job.controller.signal.throwIfAborted();return saveProject(project,job.controller.signal);});saveQueue.current=pending;await pending; + if(!isCurrent(job))throw new Error("Recovery cancelled. The validated project remains saved separately."); + await restoreProject(project);await refreshHistory(); + }finally{if(isCurrent(job)){setProjectWorking(false);operation.current=null;}} + }; + const removeSavedProject = async (project: SavedProjectSummary) => { + setDeletingProjectId(project.id); + try { await deleteSavedProject(project.id); setRecentProjects((projects) => projects.filter((item) => item.id !== project.id)); setProjectPendingDelete(null); setHistoryMessage(""); } + catch (error) { setHistoryMessage(error instanceof Error ? error.message : "Could not delete this project."); } + finally { setDeletingProjectId(null); } + }; + const seek = useCallback((time: number) => { + frameRequest.current++; reviewBounds.current = null; setReviewMode("source"); + videoRef.current?.pause(); queuedSeek(time); + }, [setReviewMode,queuedSeek]); + const seekPreviewTo = async (point: "beginning" | "left" | "right") => { + if (point !== "right" || !file) { seek(point === "beginning" ? 0 : trimStart); return; } + const ticket = ++frameRequest.current; + const time = await lastIncludedFrame(file, trimEnd).catch(() => null); + if (ticket !== frameRequest.current) return; + seek(Math.max(trimStart, time ?? trimEnd - 1 / (metadata?.frameRate ?? 30))); + setFrameMessage(time === null ? "Out-point inspection is approximate in this browser." : "Showing the last included source frame; the out point is excluded."); + }; + const stepFrame = async (direction: -1 | 1) => { + if (!file || !videoRef.current) return; + videoRef.current.pause(); reviewBounds.current = null; setReviewMode("source"); cancelSeek(); + const time = videoRef.current.currentTime, ticket = ++frameRequest.current; + const target = await adjacentFrame(file, time, direction).catch(() => null); + if (ticket !== frameRequest.current) return; + seek(target ?? time + direction / (metadata?.frameRate ?? 30)); + setFrameMessage(target === null ? "Approximate frame step; this browser cannot decode individual source timestamps." : "Stepped using decoded source frame timestamps."); + }; + const jumpToCut = (direction: "previous" | "next") => { + const time = videoRef.current?.currentTime ?? 0; + const target = direction === "next" ? sceneTimes.find((value) => value > time + 0.000001) : [...sceneTimes].reverse().find((value) => value < time - 0.000001); + if (target !== undefined) seek(target); + }; + const playRange = (start: number, end: number, kind: "selection" | "context") => { const video = videoRef.current; - if (!video) return; - const cuts = result?.sceneMarkers ?? sceneTimes; - const target = direction === "next" - ? cuts.find((time) => time > video.currentTime + 0.05) - : [...cuts].reverse().find((time) => time < video.currentTime - 0.05); - if (target !== undefined) { - selectedPreview.current = false; - video.currentTime = target; - } - }, [result, sceneTimes]); - - const previewSelection = useCallback(() => { + if (!video || end <= start) return; + cancelSeek(); frameRequest.current++; video.pause(); + reviewBounds.current = { start, end, kind }; setReviewMode(kind); + video.currentTime = start; + void video.play().catch(() => { reviewBounds.current = null; setErrorMessage("Playback could not start. Check that this browser supports the source codec."); }); + }; + const previewSelection = () => playRange(trimStart, trimEnd, "selection"); + const togglePlayback = () => { const video = videoRef.current; if (!video) return; - selectedPreview.current = true; - video.currentTime = trimStart; - void video.play().catch(() => { selectedPreview.current = false; }); - }, [trimStart]); - - const handleVideoPlay = useCallback(() => { - const video = videoRef.current; - if (video && selectedPreview.current && (video.currentTime < trimStart || video.currentTime >= trimEnd)) video.currentTime = trimStart; - }, [trimEnd, trimStart]); - + if (!video.paused) { video.pause(); return; } + cancelSeek(); + if (loopRef.current) { playRange(video.currentTime >= trimStart && video.currentTime < trimEnd ? video.currentTime : trimStart, trimEnd, "selection"); if (reviewBounds.current) reviewBounds.current.start = trimStart; return; } + const bounds = reviewBounds.current; + if (bounds && (video.currentTime < bounds.start || video.currentTime >= bounds.end - 0.00001)) video.currentTime = bounds.start; + void video.play().catch(() => setErrorMessage("This source could not start playback in this browser.")); + }; + const reviewContext = (point: "left" | "right") => { + const boundary = point === "left" ? trimStart : trimEnd; + const context = { start: Math.max(0, boundary - contextSeconds), end: Math.min(duration, boundary + contextSeconds) }; + setLastContext(context); playRange(context.start, context.end, "context"); + }; + const handleVideoPlay = () => { + setPlaying(true); + const video = videoRef.current, bounds = reviewBounds.current; + if (video && loopRef.current && !bounds) { reviewBounds.current = { start: trimStart, end: trimEnd, kind: "selection" }; setReviewMode("selection"); } + const currentBounds = reviewBounds.current; + if (video && currentBounds && (video.currentTime < currentBounds.start || video.currentTime >= currentBounds.end)) video.currentTime = currentBounds.start; + }; const handleVideoTimeUpdate = useCallback((time: number) => { setCurrentTime(time); + const video = videoRef.current, bounds = reviewBounds.current; + if (video && bounds && time >= bounds.end && !video.paused) { + if (bounds.kind === "selection" && loopRef.current) { video.currentTime = bounds.start; void video.play().catch(() => {}); } + else { video.pause(); video.currentTime = Math.max(bounds.start, bounds.end - 0.000001); } + } + }, [setCurrentTime]); + useEffect(() => { + const bounds = reviewBounds.current; + if (bounds?.kind !== "selection") return; + bounds.start = trimStart; bounds.end = trimEnd; const video = videoRef.current; - if (video && selectedPreview.current && time >= trimEnd) { - video.pause(); - video.currentTime = trimEnd; - selectedPreview.current = false; + if (video && (video.currentTime < trimStart || video.currentTime >= trimEnd)) { + if (loopRef.current && !video.paused) video.currentTime = trimStart; + else video.pause(); } - }, [trimEnd]); - - const resetEditor = () => { - projectIdentity.current = null; - pendingEditRange.current = null; - setFile(null); - setObjectUrl((previous) => { - if (previous) URL.revokeObjectURL(previous); - return null; - }); - clearResult(); - setStatus("IDLE"); - setProgress(""); - setErrorMessage(null); - setDuration(0); - setCurrentTime(0); - setTrimStart(0); - setTrimEnd(0); - setSceneTimes([]); - setScenesDetected(false); - setSceneScanStatus("waiting"); - setSceneScanMessage(""); - setSnapToCuts(false); - setIncludeSceneDetection(false); - setIncludeCaptions(false); - setSteps({ upload: "waiting", trimming: "waiting", sceneDetection: "waiting", captions: "waiting" }); - setStepMessages({}); - setHistoryMessage(""); + }, [trimStart, trimEnd]); + const handleVideoPause = () => { + setPlaying(false); + const video = videoRef.current, bounds = reviewBounds.current; + if (video?.ended && bounds?.kind === "selection" && loopRef.current) { video.currentTime = bounds.start; void video.play().catch(() => {}); } + }; + const fullscreen = async () => { + try { if (document.fullscreenElement) await document.exitFullscreen(); else await videoRef.current?.parentElement?.requestFullscreen(); } + catch { setFrameMessage("Fullscreen is unavailable. You can still use the source preview here."); } + }; + const pictureInPicture = async () => { + try { if (document.pictureInPictureElement) await document.exitPictureInPicture(); else { await videoRef.current?.requestPictureInPicture(); if(captionCues.length||edits.titles?.length||edits.images?.length||spec?.framing)setFrameMessage("Picture in picture shows the primary source. Use the composed preview or rendered MP4 to inspect framing, titles, images and styled captions."); } } + catch { setFrameMessage("Picture in picture is unavailable for this source or browser."); } + }; + const resetEditor = async () => { + try { await persist(); } catch { return; } + cancelOperation(); cancelSeek(); reviewBounds.current = null; setPlaying(false); setReviewMode("source"); setLastContext(null); frameRequest.current++; + setAssemblyMedia({});setClipOutputs([]);setSequencePreview(null);setSelectedRange(0);setFile(null); setIdentity(null); setObjectUrl(null); setMetadata(null); setResult(null); + dispatch({ type: "reset", value: empty }); setCurrentTime(0); setErrorMessage(null); setProgress(""); setHistoryMessage(""); setSaveStatus("Not saved yet"); + }; + const onWorkspaceKeyDown = (event: React.KeyboardEvent) => { + const target = event.target as HTMLElement; + if (event.nativeEvent.isComposing || busy || target.closest("input, textarea, select, dialog, video, [contenteditable=true]")) return; + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") { event.preventDefault(); dispatch({ type: event.shiftKey ? "redo" : "undo" }); return; } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "y") { event.preventDefault(); dispatch({ type: "redo" }); return; } + if (event.ctrlKey || event.metaKey || event.altKey) return; + if (target.closest("button, a, [role=slider]")) return; + if (event.code === "Space") { event.preventDefault(); togglePlayback(); } + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); if (event.shiftKey) seek(currentTime + (event.key === "ArrowLeft" ? -1 : 1)); else void stepFrame(event.key === "ArrowLeft" ? -1 : 1); } + if (event.key.toLowerCase() === "i" && !validateRange(currentTime, trimEnd, duration, metadata?.frameRate)) edit({ ...edits, ranges: edits.ranges.map((range,index)=>index===activeRangeIndex?{...range,start:currentTime}:range) }, "Set in point"); + if (event.key.toLowerCase() === "o" && !validateRange(trimStart, currentTime, duration, metadata?.frameRate)) edit({ ...edits, ranges: edits.ranges.map((range,index)=>index===activeRangeIndex?{...range,end:currentTime}:range) }, "Set out point"); }; - - const busy = status === "PROCESSING"; - const previewSrc = result?.videoUrl ?? objectUrl; - const statusCopy = status === "IDLE" ? null : { PROCESSING: "Working on your device", COMPLETE: "Export complete", FAILED: "Couldn’t finish export" }[status]; return ( <> -
+
-

{file ? file.name : "New project"}

{file ? "Source clip" : "Import a video to begin"}

+

{file ? edits.projectTitle?.trim()||file.name : "New project"}

{file ? displayedSaveStatus : "Import a video to begin"}

{statusCopy &&
@@ -522,125 +707,125 @@ export default function Editor() {
}
- {!file && } + {!file && <>
} {file && previewSrc && ( -
-
-
- Preview - {status === "COMPLETE" ? "Processed clip" : "Original clip"} -
- { - setDuration(videoDuration); - const restoredRange = pendingEditRange.current; - pendingEditRange.current = null; - setTrimStart(restoredRange ? Math.min(restoredRange.start, videoDuration) : 0); - setTrimEnd(restoredRange ? Math.min(restoredRange.end, videoDuration) : videoDuration); - }} - onTimeUpdate={handleVideoTimeUpdate} - onPlay={handleVideoPlay} - /> - -
-
- Trim range - {(result?.sceneMarkers ?? sceneTimes).length} scene {(result?.sceneMarkers ?? sceneTimes).length === 1 ? "cut" : "cuts"} +
+ +
+
+
Source preview{formatPreviewTime(currentTime)}
+ edit({...edits,framing},"Reposition image")} nativeCaptions={nativeCaptionPreview} onCaptionError={onCaptionPreviewError} onTimeUpdate={handleVideoTimeUpdate} onPlay={handleVideoPlay} onPause={handleVideoPause} + onRateChange={setPlaybackRate} onVolumeChange={setPreviewVolume} onError={() => setErrorMessage("This browser cannot preview the source codec. Your original file remains available to download.")} /> + {(captionCues.length>0||nativeCaptionPreview)&&

{nativeCaptionPreview?"Native source caption preview · render a sequence preview to inspect framing, titles and images":burnCaptions?"Styled preview · captions will be burned into the MP4":"Styled preview · MP4 has no added captions until burned-in delivery is selected"}

} + void stepFrame(direction)} onJump={(point) => void seekPreviewTo(point)} onSelection={previewSelection} + onLoop={(value) => { setLoopSelection(value); loopRef.current = value; if (value && videoRef.current && !videoRef.current.paused) previewSelection(); }} + onRate={(rate) => { if (videoRef.current) videoRef.current.playbackRate = rate; setPlaybackRate(rate); }} + onVolume={(volume) => { if (videoRef.current) { videoRef.current.muted = false; videoRef.current.volume = volume; } setPreviewVolume(volume); }} + onContext={reviewContext} onContextSeconds={setContextSeconds} onReplay={() => { if (lastContext) playRange(lastContext.start, lastContext.end, "context"); }} onFullscreen={() => void fullscreen()} onPip={() => void pictureInPicture()} pipSupported={pipSupported} /> + +
+ + +
Shortcuts

Focus the workspace: Space plays/pauses; I/O set in/out; arrows step frames; Shift+arrows seek one second; Ctrl/⌘ Z undoes; Ctrl/⌘ Shift Z redoes. Text fields keep normal typing.

- { setTrimStart(start); setTrimEnd(end); }} - onScrub={(time) => { if (videoRef.current) videoRef.current.currentTime = time; }} - /> -
-
- - - - -
-
- - - - -
+ {frameMessage &&

{frameMessage}

} +
+ { if (!validateRange(start, end, duration, metadata?.frameRate)) changeRange(start,end,"Change trim"); }} onScrub={seek} /> + void addImage(image)} onRelink={(image,file)=>void relinkImage(image,file)} onChange={(images,action)=>edit({...edits,images},action)}/> + void addTitle()} onChange={(titles,action,group)=>edit({...edits,titles},action,group)}/> + updateCaptionCues(cues,group,action??"Edit assembly primary captions")} onRelinkImage={(image,file)=>void relinkImage(image,file)} disabled={busy} onPrimary={()=>void addAssemblySource(file,true)} onImport={selected=>void addAssemblySource(selected)} onRelink={(source,selected)=>void relinkAssemblySource(source,selected)} onChange={(assembly,action,group)=>edit({...edits,assembly},action,group)} onPreview={()=>void exportAssembly(true)}/> + playRange(start,end,"context")} onApply={pauses=>{let ranges=edits.ranges;for(const pause of pauses)ranges=subtractInterval(ranges,pause.start,pause.end,()=>crypto.randomUUID());const error=ranges.length>100?"Keep no more than 100 ranges.":validateSequence(ranges,identity?.id??"",duration,metadata?.frameRate);if(error)setErrorMessage(error);else{edit({...edits,ranges},"Remove reviewed pauses");setSelectedRange(0);}}}/> + edit({...edits,clips},action)} onAdd={()=>edit({...edits,clips:[...(edits.clips??[]),{id:crypto.randomUUID(),name:"Clip "+((edits.clips?.length??0)+1),range:{...activeRange,id:crypto.randomUUID()},preferences:{...exportPreferences},framing:{...framing},selected:true}]},"Add named clip")} onRun={()=>void exportClips()} onCancel={()=>clipJobRef.current?.abort()}/> + source.reference.metadata.hasAudio):metadata?.hasAudio??false} disabled={busy} onChange={(framing,audioAdjustments,action,group)=>edit({...edits,framing,audioAdjustments},action,group)} onPreview={()=>void handleProcess(true)}/> + {setSelectedRange(index);seek(time??edits.ranges[index]?.start??0);}} onChange={(ranges,action)=>edit({...edits,ranges},action)} onPreview={()=>void handleProcess(true)}/> + {sequencePreview&&

{sequencePreview.revision===revision?"Current":"Earlier"} rendered sequence preview

} + edit({...edits,sceneSettings:settings},"Change scene scan settings")} onScan={(replace)=>void detectSceneCuts(replace)} onCuts={updateSceneCuts} + onRange={(start,end)=>{if(!validateRange(start,end,duration,metadata?.frameRate))changeRange(start,end,"Select scene");}}/> + +
+
+ + {result && MP4} + {outputCues.length > 0 && Current subtitles (.srt)} + {outputCues.length>0&&Current subtitles (.vtt)} + {result?.captionsVtt && Export subtitles (.vtt)} + + {busy && } +
- {sceneScanMessage &&

{sceneScanMessage}

} + {reviewIssues.length > 0 &&

{reviewIssues.length} caption timing or readability flags to review in Captions before delivery. Wording is preserved; subtitles use the selected range.

} + {(progress || errorMessage) &&

{errorMessage ?? progress}

} + {(status==="PROCESSING"||captionWorking||sceneScanStatus==="working") && } + {projectProgress&&

{projectProgress}

} + {historyMessage &&

{historyMessage}

} + {shortCaptionIntersections > 0 &&

{shortCaptionIntersections} caption intersections are too brief for millisecond subtitle timestamps and are omitted from the selected subtitle file. The source cues remain saved.

} +
Export and storage details +
Project name and backups + +

The source filename stays unchanged. Duplicate creates independent decisions and shares stored source/export media until a new export is made.

+
+

Project files require the matching video. Bundles include that source without reading it all into memory. Both preserve editing decisions and exclude previous rendered MP4s; download those separately.

+ {projectWorking&&} + +
+ edit({...edits,...settings},"Apply output preset")}/> + {spec&&edit({...edits,exportPreferences:preferences},action,group)}/>} +

Export: {outputFileName(outputStem,exportPreferences)} · {formatPreviewTime(sequenceLength)} · {spec ? spec.width + " × " + spec.height + " · " + (spec.audio ? "source audio" : "no audio") : "Checking source"} · {outputTitles.length} timed title sections · source frame timing · {burnCaptions?"captions burned into video"+(captionDelivery==="both"?" and delivered as files":""):"captions delivered separately"}.

+
Frame capture and edit report

Source captures preserve the decoded image. Composed captures include framing and the edited caption layer at that source frame; neither includes safe guides. Reports contain local editorial wording and notes.

+ + {result &&

{result.revision === revision ? "Latest export" : "Earlier export"} · {result.settings.captions==="sidecar"?"subtitle files":result.settings.captions==="burned"?"burned-in captions":"burned-in captions and files"} · revision {result.revision} · {(result.videoBlob.size / 1024 / 1024).toFixed(2)} MB · {formatPreviewTime(result.duration)} · {result.metadata?result.metadata.width+" × "+result.metadata.height+" · "+(result.metadata.hasAudio?"audio present":"no audio")+" · "+(result.metadata.videoCodec??"video"):(result.settings.width+" × "+result.settings.height)} · {result.settings.preferences?.quality??"legacy quality"}

Review exported video
} + {saveStatus.startsWith("Save failed") && } +
+ Manage saved projects + void checkModelCache()}/> +

Projects remain until you delete them. Deleting a project removes only media no longer shared by another project; the current project stays open. Browser storage is not a backup.

+
    {recentProjects.map((project) =>
  • + {project.title??project.fileName}{project.id === identity?.id ? " · Current project" : ""} + +
  • )}
+
+ {identity?.sourceAvailable ? "Download original source" : "Download saved source export"} + {metadata?.hdr &&

This source has HDR or wide-color metadata. Export the unchanged full source to preserve its color; HDR trimming and re-encoding are currently unavailable.

} + +
- -
-
-

Optional processing

- Off by default for a faster export +
- - - -
- - {result && MP4} - {result?.captionsUrl && captionCues.length > 0 && Subtitles (.srt)} - {result && } - -
- - {(progress || errorMessage) &&
- {!errorMessage && busy && } - {!errorMessage && !busy && } - {errorMessage ?? progress} -
} - {result?.captionError &&
- Captions were not created: {result.captionError} The video export is still available. -
} - {historyMessage &&

Recent project history: {historyMessage}

} - {result && } -
- -
- +
)} {!file &&
-

Recent projects

Finished clips are saved in this browser.

+

Recent projects

Drafts and exports are saved in this browser. Projects stay until you delete them; browser storage is not a backup.

{historyMessage && {historyMessage}}
{recentProjects.length ?
    {recentProjects.map((project) =>
  • - {project.fileName}{new Date(project.createdAt).toLocaleDateString()} · {project.captionsVtt ? "Captions saved" : "No captions"} + {project.title??project.fileName}{new Date(project.createdAt).toLocaleDateString()} · {project.exportRevision !== undefined ? "Export saved" : "Draft"} · {project.captionsVtt ? "Captions saved" : "No captions"}
  • )} -
:

Your five most recent exports will appear here.

} + :

Your saved drafts and exports will appear here.

}
}
{ event.preventDefault(); if (deletingProjectId === null) setProjectPendingDelete(null); }} aria-labelledby="delete-project-title" className="m-auto w-[min(420px,calc(100vw-2rem))] rounded-2xl border border-[#594567] bg-[#191421] p-0 text-ink shadow-[0_24px_90px_rgba(0,0,0,0.65)] backdrop:bg-black/75"> diff --git a/frontend/components/ExportSettings.tsx b/frontend/components/ExportSettings.tsx new file mode 100644 index 0000000..e9aa80f --- /dev/null +++ b/frontend/components/ExportSettings.tsx @@ -0,0 +1,19 @@ +"use client"; +import {EXPORT_PRESETS,type ExportPreferences,type OutputSpec} from "@/lib/output-spec"; +interface Props {preferences:ExportPreferences;spec:OutputSpec;disabled:boolean;onChange:(value:ExportPreferences,action:string,group?:string)=>void} +export default function ExportSettings({preferences,spec,disabled,onChange}:Props){ + const control="min-h-10 rounded border border-line bg-well px-2 text-ink"; + const patch=(field:keyof ExportPreferences,value:string)=>onChange({...preferences,[field]:value},"Change export "+field,field==="filePrefix"||field==="fileSuffix"?"export-name-"+field:undefined); + return
Export settings · {spec.width} × {spec.height} · {preferences.quality} + +

Match source resets resolution, quality and audio. Framing, titles, images and audio adjustments are controlled separately; unchanged full-length MP4s can retain their original bytes. Smaller file uses up to 720p and stronger compression. High quality re-encodes at source dimensions.

+ +

Resolution limits the output pixel size. Framing can crop or enlarge the image to fill a different canvas; inspect the composed preview.

+ +

Trims and changed settings require encoding. Both processors follow the same quality intent; exact file size and pixels vary with footage and encoder.

+ +

{spec.audio?"Output includes source audio, encoded as AAC when needed.":"Output has no audio track."} Source preview volume remains a review control.

+

Names combine prefix, project or clip name, and suffix. Invalid filename characters become hyphens. Each clip captures these rules; reusable output presets include them.

+

Frame timing: original source timestamps, including variable frame rate. Encoding uses MP4 / H.264 and {Math.round(spec.audioBitrate/1000)} kb/s AAC. Caption delivery is set in Caption style and delivery; SRT/VTT downloads can be used without encoding video.

+
; +} diff --git a/frontend/components/ImagePanel.tsx b/frontend/components/ImagePanel.tsx new file mode 100644 index 0000000..e6c8fbe --- /dev/null +++ b/frontend/components/ImagePanel.tsx @@ -0,0 +1,12 @@ +"use client"; +import type {ImageOverlay} from "@/lib/image-tools"; +import {validateRange} from "@/lib/timing"; +import TimeField from "./TimeField"; +import CommitSlider from "./CommitSlider"; +export default function ImagePanel({images,media,duration,frameRate,disabled,onImport,onRelink,onChange}:{images:ImageOverlay[];media:Record;duration:number;frameRate?:number|null;disabled:boolean;onImport:(file:File)=>void;onRelink:(image:ImageOverlay,file:File)=>void;onChange:(images:ImageOverlay[],action:string)=>void}){ + return
Images and logos · {images.length}

Use a local PNG, JPEG or WebP up to 2 MB, 4096 pixels per edge and 8 megapixels (16 megapixels combined per render). PNG/WebP transparency is preserved. Images use source times and repeat with kept ranges. Framing sits below images; titles and captions sit above them. Asset files save separately from decisions and are included in source-inclusive backups.

+
    {images.map((image,index)=>
  1. {image.reference.fileName} · {image.reference.metadata.width} × {image.reference.metadata.height}

    {!media[image.reference.fingerprint]&&} +
    {(["start","end"] as const).map(point=>)}
    +
    {(["x","y","size","opacity"] as const).map(key=>)}
    +
  2. )}
; +} diff --git a/frontend/components/MarkerPanel.tsx b/frontend/components/MarkerPanel.tsx new file mode 100644 index 0000000..d681431 --- /dev/null +++ b/frontend/components/MarkerPanel.tsx @@ -0,0 +1,31 @@ +"use client"; +import { useState } from "react"; +import TimeField from "./TimeField"; +import { formatTime } from "@/lib/timing"; +import type { EditorialMarker } from "@/lib/scene-tools"; +interface Props { markers:EditorialMarker[]; duration:number; disabled:boolean; getPlayhead:()=>number; onSeek:(time:number)=>void; onChange:(markers:EditorialMarker[],action:string,group?:string)=>void } +export default function MarkerPanel({markers,duration,disabled,getPlayhead,onSeek,onChange}:Props) { + const [search,setSearch]=useState(""),[name,setName]=useState("Hook"),[message,setMessage]=useState(""); + const change=(id:string,update:Partial,action:string,group?:string)=>onChange(markers.map(marker=>marker.id===id?{...marker,...update}:marker),action,group); + const add=()=>{ + const time=getPlayhead(); + if(!name.trim()){setMessage("Give the marker a name.");return;} + if(time<0||time>=duration){setMessage("Place the playhead within the source to add a marker.");return;} + onChange([...markers,{id:crypto.randomUUID(),time,name:name.trim(),note:"",color:"lavender"}],"Add editorial marker");setMessage(""); + }; + const visible=markers.filter(marker=>(marker.name+" "+marker.note).toLocaleLowerCase().includes(search.toLocaleLowerCase())).sort((a,b)=>a.time-b.time); + return
Editorial markers · {markers.length} +

Name a hook, quote, pickup, problem or chapter. These source notes stay separate from detected cuts and survive rescans.

+
+ + {message &&

{message}

} +
    {visible.map(marker=>
  • +
    + + +