Video Editor¶
Moved out of README.md (see development.md for running from source and an overview, and the README for installing).
Video Editor (my_scripts/video/video_editor.py)¶
General-purpose quick-edit script, meant as the base for more one-off editing operations later - unlike the other video/ scripts (which each do one specific job), this one composes several independent, optional operations in a single ffmpeg pass:
- Watermark/logo overlay (--watermark_path, --watermark_position top-left|top-center|top-right|bottom-left|bottom-center|bottom-right|center, --watermark_scale as a fraction of output width (now a slider/field in both UIs, not just a CLI-only flag), --watermark_opacity 0.0-1.0, default 1.0 = fully opaque) - geometry (size/position, aspect-ratio preserved) is computed in pure Python (utils/video/video_editor.py) from the watermark image's real dimensions, not guessed. Both UIs show a live preview of this before you run anything, using the same compute_watermark_geometry()/resolve_output_dimensions() the real ffmpeg command uses - so what you see is what the render will produce, not an approximation - and updates live as you change the watermark file, its position/scale, or the vertical/horizontal toggle. --watermark_opacity applies format=rgba,colorchannelmixer=aa=X before the overlay (the same technique rocket_shorts_publisher.py already used) - ignored with --hwaccel, since the nv12 pixel format the watermark gets converted to there before overlay_cuda has no alpha channel at all, so there's no way to express partial transparency on that path without dropping back to CPU (which would defeat the point of --hwaccel); the render still succeeds, just fully opaque.
- Free placement by dragging the preview (--watermark_x/--watermark_y, both required together, each a 0.0-1.0 fraction of output width/height for the watermark's center) - overrides --watermark_position entirely when both are set, for a spot no fixed preset covers. Both UIs let you just drag the watermark thumbnail around directly on the live preview instead of typing fractions by hand (desktop: a Tkinter drag on wm_preview_label; web: a mousedown/mousemove/mouseup drag on #wmPreviewImg) - dragging derives the center fraction from wherever you drop it and writes it back into the same fields. Using the center rather than the top-left corner means changing the scale afterward doesn't shift the point you actually dragged to. Picking a position from the dropdown again clears the custom fraction so the preset takes over; a dedicated "↩️ בטל מיקום מותאם"/"clear custom position" button does the same without touching the dropdown.
- Old watermark removal/replacement (--remove_watermark_position with the same position choices, --remove_watermark_width_fraction/--remove_watermark_height_fraction as fractions of the input's own width/height, default 0.2/0.12) - erases that rectangular region with ffmpeg's delogo filter, run at the input's native resolution before any scaling. This blurs/interpolates the region from its surroundings rather than reconstructing genuinely hidden detail - clean on simple backgrounds, a visible soft patch on busy/detailed ones. Combine with --watermark_path (same run) to place a new logo over the erased area, or anywhere else - that's a "replace watermark A with watermark B" edit in one pass, not two separate scripts.
- Vertical conversion (--vertical) - same 1080x1920 TikTok/Shorts crop as timelapse_maker.py, or 1920x1080 horizontal (default) otherwise. --smart-crop (vertical only, ignored otherwise) picks where that crop window sits instead of always centering it - utils/media/smart_crop.py's compute_horizontal_crop_offset() samples ~40 frames, tracks where most of the motion happens via optical flow, and returns a -1.0 (all the way left) to 1.0 (all the way right) offset baked directly into the crop filter's x expression, so action happening off-center doesn't get cut off by a naive center crop. A failed analysis (or none requested) falls back to the original centered behavior exactly. See --blur-background below for a way to convert to vertical without cropping the source at all.
- Blurred background instead of black bars, or fitting a horizontal source into a vertical frame without cropping (--blur-background, without --hwaccel, ignored otherwise) - when the source doesn't natively fill the output frame and would otherwise get plain black padding, this fills the whole frame with a scaled-up, cropped, and blurred (gblur) copy of the same source instead, with the normally-scaled content (aspect-ratio preserved, never cropped) centered on top (split into two branches + overlay, a small filter sub-graph rather than the simple linear chain the other options use). Works the same regardless of orientation, so combined with --vertical it's the way to place a horizontal (16:9) recording into a 9:16 TikTok/Shorts frame without cutting off its sides the way the normal --vertical crop would - the full width fits, and the blurred backdrop fills the letterbox bars above/below instead. --smart-crop is ignored (logged) when combined with --blur-background, since nothing gets cropped at all in that mode - there's no crop window left to reposition. Not available with --hwaccel (gblur has no proven CUDA equivalent in this project's ffmpeg build).
- Timelapse speed-up (--target-seconds) - reuses utils/media/timelapse.py's speed/atempo logic exactly; 0 (default) leaves the original speed untouched.
- Trim range (--trim-start <seconds>, --trim-duration <seconds>) - cuts the clip down to a sub-range before any other processing, using -ss/-t as input options (fast seek, applied only to the main video input, not to a watermark image passed alongside it) rather than re-encoding the whole file first. --trim-duration alone (no --trim-start) trims from the end; a range that would leave nothing after clamping to the file's actual length is skipped with an [ERROR] rather than attempted. --trim-silence is the automatic alternative - runs ffmpeg's silencedetect filter first (audio-only, fast, discarded output) to find silence specifically at the very start/end of the clip (not in the middle - this trims, it doesn't strip mid-clip silence generally) and computes the same kind of range automatically; combining it with an explicit --trim-start/--trim-duration is ignored in favor of the explicit range (logged, not an error).
- Denoise / sharpen (--denoise applies hqdn3d before scaling, --sharpen applies unsharp after) - both ignored with --hwaccel (no CUDA equivalent used in this project). Useful for cleaning up compressed/noisy capture footage, or crisping up output that got a bit soft from scaling.
- Skip redundant scale - if the source is already at exactly the target resolution (1920x1080 horizontal / 1080x1920 vertical, or the effective target when --preserve-resolution is active - see below), the scale/pad/crop step is skipped entirely (logged) instead of re-processing pixels that wouldn't actually change - only without --hwaccel (skipping the CUDA scale step would leave frames in an unverified state for the filters after it, not worth the marginal gain).
- Preserve source resolution instead of forcing 1080p (--preserve-resolution) - by default the output is always resized to the fixed 1920x1080 (horizontal) / 1080x1920 (vertical) box regardless of the source, which means a 4K (or higher) recording gets quietly downscaled. This flag makes resolve_effective_output_dimensions() (utils/video/video_editor.py) derive the target from the source's own probed dimensions instead: horizontal output becomes an exact pass-through of the input's own width/height (rounded up to even, since codecs require even dimensions for 4:2:0 chroma subsampling) - no resize/pad at all; vertical output still crops to a 9:16 box (that's the entire point of --vertical), but the box is sized from the source's own resolution (its larger dimension becomes the crop box's height) instead of a fixed 1080x1920, so a higher-than-1080p source doesn't get needlessly shrunk during the crop. A source already smaller than the fixed defaults isn't upscaled either way - "preserve" never means "enlarge."
- Skip the _edited_horizontal/_edited_vertical output filename suffix (--no-suffix) - by default every output is named <source>_edited_horizontal.mp4/<source>_edited_vertical.mp4 to make source vs. output obvious even if they ever end up side by side. This flag names it <source>.mp4 exactly instead - outputs already always land in a separate workspace/studio/<orientation>/ folder from the source, so there's no risk of a name collision with the original file even without the suffix.
- Export codec (--codec h264|hevc|av1) - h264 is CPU (libx264, universal compatibility), hevc/av1 use NVENC (GPU, requires an NVIDIA card) at settings matching the quality presets already used by rocket_shorts_publisher.py. --nvenc-preset p1-p7 and --nvenc-cq <n> override NVENC's default preset/quality for a single run (ignored with a log line, not an error, if combined with --codec h264).
- GPU-accelerated filtering (--hwaccel, NVENC codecs only) - without it, the entire pipeline (decode, delogo, scale/pad, watermark overlay) runs on CPU and only the final encode touches the GPU; with --codec hevc/av1 --hwaccel, decode and scale/pad/overlay all run on CUDA (scale_cuda/pad_cuda/overlay_cuda) too - delogo still has no CUDA filter in ffmpeg at all, so watermark removal specifically still drops to CPU for that one step and re-uploads immediately after. This is what the sidebar's separate "Encoder (NVENC)" reading (see "Key Components" above) is for: without --hwaccel, that reading can sit near 0% during a whole render even though the job is genuinely GPU-encoding, because the CPU-bound filters upstream are the actual bottleneck, not the encoder.
- Encode several files at once, and dial resource usage up or down (--parallel <N>, --effort low|medium|high) - --parallel (default 1, sequential like before) runs up to N files' ffmpeg processes concurrently via a ThreadPoolExecutor (each thread just launches and waits on its own subprocess, so the GIL isn't a bottleneck); the batch-wide render_lock still wraps the whole run as one unit, same as before, so a --parallel run still counts as a single job to the rest of the app (GUI/bot) for cross-process GPU coordination purposes - run_ffmpeg_with_progress_and_nvenc_retry()'s existing session-limit retry (see below) already handles two NVENC encodes racing for a limited number of concurrent hardware sessions, so combining --parallel with --codec hevc/av1 degrades to a wait-and-retry instead of a hard failure. --effort (default high = unrestricted, identical to the old behavior) caps -threads/-filter_threads (medium = half the CPU core count, low = a fixed 2) - -filter_threads matters for every codec since the filter graph (scale/pad/watermark/etc.) runs on CPU even when NVENC does the final encode, unless --hwaccel is also active; for hevc/av1, medium/low also drop to a faster/lighter NVENC preset (p4/p2) unless --nvenc-preset was passed explicitly (which always wins). Useful either to keep the machine usable for other things during a long background batch, or to push a multi-file batch through faster on a machine that can spare the resources.
- Output framerate & HDR preservation (--fps, HDR handling fully automatic) - --fps left unset (the new default) preserves each file's own original framerate exactly, including fractional NTSC rates (29.97/59.94/23.976, read from ffprobe's r_frame_rate, falling back to avg_frame_rate if that's unusable) instead of forcing every file to a fixed 30fps regardless of source - a batch of mixed-rate clips gets each file's own correct rate, computed per-file in process_video() (resolve_output_fps() in utils/video/video_editor.py), not one global value for the whole run. Pass --fps <N> (a plain number or an exact fraction like 30000/1001) to override for the whole run instead - validated once up front (fails fast on a bad value instead of partway through a batch). Separately, HDR source footage (ffprobe's color_transfer reporting smpte2084/PQ or arib-std-b67/HLG) is detected automatically per file and handled according to --codec: with hevc/av1 (NVENC, the only codecs here with real 10-bit/HDR10 metadata support), the output keeps full HDR - 10-bit (-pix_fmt p010le) plus the source's exact -color_primaries/-color_trc/-colorspace tags passed straight through unchanged (--hwaccel is dropped for that one file only, logged, since the CUDA filter chain here is 8-bit-only); with h264 (the default codec, no real HDR support in this project), the source instead gets proper HDR-to-SDR tone-mapping (zscale+tonemap=hable+zscale back to bt709) before encoding, instead of ffmpeg's naive default of just truncating 10-bit values to 8-bit, which produces a washed-out/dull image rather than an error. --thumbnail always tone-maps to SDR regardless of what the main output did - a thumbnail is viewed as a flat image, not on an HDR display, so carrying HDR tags there would only make it look worse.
- Color grading (--lut <path.cube>, --auto-levels) - --lut loads a 3D LUT (the common Adobe/DaVinci .cube format) via ffmpeg's lut3d filter, applied last in the chain (after resize/sharpen - a creative grade over the final-resolution pixels); a missing/unreadable file is logged as a [WARNING] and the run continues without it rather than failing the batch. --auto-levels applies ffmpeg's normalize filter before scaling (stretches each frame's brightness range to full contrast automatically) - useful for dark/flat capture footage that doesn't use the full brightness range.
- Smooth frame-rate conversion (--smooth-fps) - swaps the plain fps= filter for minterpolate (real motion-compensated interpolation, generating genuine in-between frames instead of just duplicating/dropping them) - mainly useful when raising the rate (e.g. --fps 60 on a 30fps source); noticeably slower than the default. Ignored with --hwaccel (no CUDA minterpolate equivalent in this project's ffmpeg build).
- Noise gate & mid-clip silence removal (--noise-gate, --remove-mid-silence, both require --keep-audio) - --noise-gate applies ffmpeg's agate filter before --loudnorm (gates out constant background hiss/hum below a threshold without touching speech, then normalizes the gated signal). --remove-mid-silence detects every silent gap in the clip (not just the start/end like --trim-silence) and removes it from video and audio together, frame-accurately in sync - a naive audio-only silenceremove would shift the audio timeline forward without touching the video and break lip-sync from the first removed gap onward, so this instead builds a matching select/aselect pair (utils/video/video_editor.py's build_silence_removal_filters()) driven by the exact same detected timestamps for both streams, with setpts=N/FRAME_RATE/TB/asetpts=N/SR/TB renumbering timestamps afterward to close the gaps. Mutually exclusive with --trim-start/--trim-duration/--trim-silence (logged, explicit trim wins) since combining "pick one sub-range" with "remove several scattered ranges" on the same file isn't well-defined.
- Video stabilization (--stabilize) - two real ffmpeg passes, like --target-size-mb's two-pass mode: vidstabdetect analyzes camera-shake and writes a transforms file to a temp path first, then vidstabtransform (first filter in the main chain, before even HDR tone-mapping - geometric correction on the rawest frames available) applies the correction during the real render. Adds real time (a full extra analysis pass over the source). Ignored with --hwaccel (no CUDA equivalent).
- Chroma key (--chromakey, --chromakey-color/--chromakey-similarity/--chromakey-blend/--chromakey-bg-color) - turns a solid color (default standard green, 0x00FF00) transparent and composites it over a plain solid-color backdrop (default black) built directly inside the filter graph (ffmpeg's color= source filter - no extra image input needed). This is a flat-color background swap, not a background-image replacer. Mutually exclusive with --blur-background (two different ways to build the base frame; logged, chromakey is ignored if both are given) and not supported with --hwaccel.
- Caption styling (--caption-font, --caption-font-size, --caption-color as #RRGGBB, --caption-position bottom|top|center, all relevant only with --captions) - utils/media/captions.py's burn_captions() used to hard-code Arial/16px/white/bottom; now builds the force_style= string from these instead, converting the hex color to ASS's reversed-byte-order format (_hex_to_ass_color()) so the flag stays a normal #RRGGBB value rather than requiring ASS's own notation. Fixed a real bug found while adding this: caption-burning is a second ffmpeg encode pass and previously never passed -c:v at all, silently re-encoding with ffmpeg's own default (effectively always h264/SDR) regardless of what --codec/HDR pass-through the main render actually used - a --codec hevc --captions run on HDR source would render correct 10-bit HDR output, then quietly downgrade it back to 8-bit SDR h264 the moment captions were burned in. burn_captions() now accepts the same codec/HDR args the main render used and re-applies them.
- Audio - dropped by default (-an); --keep-audio maps it through (mapped directly, without touching it, unless a speed change and/or --loudnorm are also active - ffmpeg won't allow a direct stream map on a stream that already went through -filter_complex). --loudnorm normalizes loudness to the same -14 LUFS target rocket_shorts_publisher.py uses by default (already includes a -1.5 dBTP true-peak ceiling, not just integrated loudness); --loudnorm-target <LUFS> overrides that target for a single run (relevant only combined with --keep-audio --loudnorm, otherwise logged as ignored). --captions burns auto-transcribed captions (utils/media/captions.py, faster-whisper, the same mechanism rocket_shorts_publisher.py already uses for TikTok exports) into the finished output afterward - requires --keep-audio (there's nothing to transcribe from an output with no audio track at all, so it's ignored without it rather than guaranteed to fail); a transcription/burn failure (e.g. no speech detected) is logged and skipped, not fatal to the rest of the run. --captions-translate (ignored without --captions) translates the captions to English instead of transcribing them in the source language - this is Whisper's own built-in task="translate" (it only ever translates to English, not to an arbitrary target language), so no extra translation API/dependency is involved.
- Recursive folder-by-folder processing - --input_dir doesn't have to be flat: find_video_files_by_folder() (utils/media/timelapse.py) walks it with os.walk, and every subfolder that directly contains video files is processed as its own group. A folder with subfolders (e.g. one per recording session) gets edited in a single run instead of one --input_dir per subfolder. Each subfolder's outputs land in the matching subfolder under workspace/studio/vertical/ or workspace/studio/horizontal/ (depending on --vertical, mirroring the input's structure), so same-named files in different subfolders never collide; files sitting directly in --input_dir itself still land flat in that same edited_videos/ folder, unchanged from before.
- Smart re-encode skip - before rendering, _compute_current_signature() hashes only the source file's own mtime+size via utils/video/video_editor.py's compute_render_signature(). If an output file and a matching .sig.json sidecar (see below) with the same signature already exist, the file is skipped entirely (logged as [SKIP], counted in the RESULT: line's skipped field), regardless of what flags this run passes - useful when re-running the same batch over a folder that only partially changed, or when turning on a new flag (e.g. --thumbnail) for new clips shouldn't force every already-processed clip in the same folder to re-render too. Pass --force to always re-render a specific run regardless.
- .sig.json sidecars live in their own _sig/ subfolder - previously sat right next to each output file (<output>.sig.json), cluttering the output folder with a non-video file per clip. utils/video/video_editor.py's resolve_sig_path() now puts them under <output_dir>/_sig/<output filename>.sig.json instead (same naming precedent as the existing _previous/ versioning subfolder) - the output folder itself now holds only videos (and thumbnails). Purely a storage-location change: the smart re-encode skip, --force versioning snapshot, and everything else that reads/writes this sidecar all resolve the same new path automatically, no behavior difference beyond where the file physically sits.
This used to also hash every output-affecting flag (codec/watermark/vertical/trim/etc., plus the watermark image's own mtime+size) into the signature, so any settings change - even one unrelated to a given file - re-rendered every already-processed clip in the folder. That's gone: only the source file's own content (mtime+size) still breaks the skip, protecting against the one case that genuinely matters (a file replaced in place under the same name - re-recorded footage with the same filename, say - correctly gets re-rendered instead of silently serving the stale old output). Because the signature's format itself changed, the very first run over a folder full of previously-rendered clips after this change still re-renders everything once (the old .sig.json files don't match the new hash) - every run after that is settings-independent again.
Every time a file does not skip, [INFO] now logs the exact reason (--force passed / no prior output / prior output has no .sig.json sidecar / source file's mtime+size changed since the stored signature) right there in the run's own output, instead of leaving that to be reverse-engineered later from file timestamps on disk.
- Two-pass VBR with a target file size (--target-size-mb <N>) - an alternative to the default CQ/CRF quality-first mode, for when you have a concrete size budget (e.g. fitting under Discord's/Twitter's upload limit). compute_target_bitrate_kbps() derives a video bitrate from the target size, the clip's duration, and the audio bitrate (if --keep-audio); get_two_pass_codec_args() then picks the actual encoding strategy per codec - h264 (libx264) runs two full separate ffmpeg passes (pass 1 writes stats only, discarded to a null muxer; pass 2 does the real encode using those stats), while hevc/av1 (NVENC) get the same effect from a single ffmpeg call using -multipass fullres (confirmed present in this project's bundled ffmpeg build for both hevc_nvenc and av1_nvenc - NVENC has no equivalent of libx264's pass-log files). Ignores --nvenc-cq when combined (VBR bitrate targeting and CQ are mutually exclusive rate-control modes).
- Preview contact-sheet (--preview-only) - instead of rendering, builds one PNG (utils/video/contact_sheet.py's generate_contact_sheet()) with a representative frame (at 20% of each clip's duration) and filename for every video that would be processed, arranged in a grid (capped at 5 columns, wrapping to more rows beyond that) - a quick visual sanity check that the right folder/subfolders were selected before committing to a batch render that could take minutes or hours. A clip whose frame can't be extracted gets a plain gray placeholder cell instead of aborting the whole preview. Doesn't touch render_lock at all - frame extraction is fast and isn't GPU-contending like a real render.
- "Time saved with --hwaccel" metric - the RESULT: line now also reports hwaccel/codec, so utils/job_history.get_hwaccel_time_comparison() can compare average seconds-per-file (not per-run, so a 50-file batch and a single-file run compare fairly) between runs that used --hwaccel and runs that didn't, no new DB column needed since it's already sitting in the existing result_json blob. Needs at least one successful run in both groups to say anything - returns None otherwise, which both UIs treat as "nothing to show yet" rather than an error. Surfaced next to the render-duration stats: the web app's "📊 סטטיסטיקות" dialog (GET /api/hwaccel_savings) and the desktop Stats tab (refreshes alongside the job history list).
- Run summary report - a [REPORT] line is now printed at the end of every batch (created/skipped/errors counts, total wall time, average seconds per created file), and the same numbers (elapsed_seconds/avg_seconds_per_file) are added to the RESULT: JSON - previously the only way to see how long a whole run actually took was indirectly, from worker.py's own external process-timing in job_history, not from the run's own output.
- Automatic retry when NVENC hits its concurrent-session limit (utils/media/ffmpeg_progress.py's run_ffmpeg_with_progress_and_nvenc_retry()) - only for --codec hevc/av1: if an NVENC render fails with one of a handful of known "session limit reached" error signatures (e.g. another app like OBS is also encoding via NVENC right now, or another parallel file in this same run) rather than some other real failure, it retries instead of failing the file outright - up to 6 times, waiting exponentially longer between attempts (15s, then 27s, 48s, ... capped at 90s, with random ±15% jitter so several files stuck on the same limit don't all retry in lockstep and collide again) instead of a flat 10s wait as before. The real bottleneck freeing up - another parallel file finishing its own full-length 4K encode - can take minutes, not seconds, which the old fixed 10s×3 budget (under a minute total) rarely survived in practice; the new budget stretches to several minutes. Any other failure (bad parameters, no GPU at all) still fails immediately on the first attempt, same as before - this only recognizes that one specific, recoverable situation.
- Versioning - snapshot before --force overwrites - --force used to silently overwrite the previous output. It now first moves the existing output (plus its .sig.json and thumbnail, if present) into a timestamped _previous/ subfolder next to it, so the last version isn't lost to an accidental/unwanted re-render. Keeps only the most recently overwritten version, not a full history - enough to recover from a mistaken --force, without accumulating unbounded copies over months of repeated use.
- Crash-safe atomic output (resumable batches) - each file now renders to a temporary <output>.partial.<ext> path, os.replace()'d (atomic on the same filesystem) to the real output path only after ffmpeg genuinely succeeds - a failed/killed render is cleaned up instead of left behind. Previously ffmpeg would still create/open the final output path before failing on something like an invalid filter graph, leaving an empty/truncated file there that looked like a completed render to the smart re-encode skip check (os.path.exists(output_path) was true even though nothing was actually encoded) - re-running the same batch after a crash now correctly sees "no output yet" for whatever file was in flight and re-renders it, instead of silently treating a broken leftover as done forever.
- Encode several files at once, and dial resource usage up or down (--parallel <N>, --effort low|medium|high|auto) - --parallel (default 1, sequential like before) runs up to N files' ffmpeg processes concurrently via a ThreadPoolExecutor (each thread just launches and waits on its own subprocess, so the GIL isn't a bottleneck); the batch-wide render_lock still wraps the whole run as one unit, so a --parallel run still counts as a single job to the rest of the app (GUI/bot) for cross-process GPU coordination - the existing NVENC session-limit retry (above) already handles two NVENC encodes racing for a limited number of concurrent hardware sessions, so combining --parallel with --codec hevc/av1 degrades to a wait-and-retry instead of a hard failure. --effort (default high = unrestricted, identical to the old behavior) caps -threads/-filter_threads (medium = half the CPU core count, low = a fixed 2) and, for hevc/av1, drops to a faster/lighter NVENC preset (p4/p2) unless --nvenc-preset was passed explicitly (which always wins). --effort auto reads live CPU/GPU load (utils/shared/hardware.py's get_system_stats()/get_gpu_stats(), the same readings behind the dashboard's CPU/GPU widgets) per file, not once for the whole batch and picks low/medium/high from whichever axis is more loaded right now (resolve_auto_effort()) - useful for a long background batch on a machine also doing other things, without having to guess one fixed level for the entire run up front.
- Proactive NVENC concurrency cap, on top of the reactive retry (--max-concurrent-nvenc <N>) - a high --parallel combined with --codec hevc/av1 used to mean every file tried to open an NVENC encode session at the same instant, so nearly all of them hit "session limit reached" together and had to lean on the retry-and-wait above just to get through a normal run - and on a busy machine could still exhaust even the longer retry budget above and fail outright if the probed limit (measured with tiny throwaway encodes, see below) doesn't hold up under real full-resolution contention. This flag (or its automatic default) caps how many NVENC encodes this run actually launches at once, independent of how many files --parallel considers in flight - a threading.Semaphore held for the file's entire encode (including any retries) rather than a first-come-first-served race. Left unset, the real limit is measured once at startup instead of guessed (utils/media/nvenc_probe.py's probe_max_concurrent_nvenc_sessions() - the same probe already used for the diagnostic dialog in both UIs - launches several genuine tiny NVENC encodes simultaneously and counts how many actually succeed together); a value here skips that startup probe and pins the cap directly instead. Irrelevant (and skipped entirely, no probe run) with --codec h264 (no NVENC involved) or --parallel 1 (nothing for this run to contend with itself over). If literally no NVENC session could be measured at all (no NVIDIA GPU, or this ffmpeg build lacks NVENC), no cap is applied and the real encode fails for its own, unrelated reason on every file, same as before this flag existed.
- Export container (--container mp4|mkv) - mkv (Matroska) is a fully flexible container that accepts every codec combination used here (h264/hevc/av1 + aac) with no other changes needed - ffmpeg picks the muxer from the output file's extension by itself. webm is deliberately not offered: it only accepts vp8/vp9/av1 video and vorbis/opus audio, so it's incompatible with h264/hevc/aac outright, not just "not implemented yet".
- Custom output filename template (--output-template "{name}_{codec}_{date}") - overrides the default <name>_edited_horizontal/vertical.mp4 naming (and --no-suffix, which it takes priority over) with placeholders resolved per file without needing to probe it first: {name} (source filename), {codec}, {orientation} (horizontal/vertical), {date} (YYYYMMDD, wall-clock at render time), {number} (see --number-videos below, blank if not active). An unknown placeholder is rejected once up front (utils/video/video_editor.py's validate_output_template()) before any rendering starts, not partway through a batch.
- Sequential video numbering (--number-videos, --number-prefix <text> default Clip, --number-start <N>) - same persistent-counter naming scheme rocket_shorts_publisher.py already uses for its own output filenames (e.g. Shorts #42), so a related pipeline that also numbers its outputs (thumbnails for a numbered episode series, say) can pick up where a previous run left off instead of restarting from 1 - but tracked in its own separate, dedicated counter file (data/video_editor_counter.txt, utils/video/video_editor_counter.py) rather than sharing rocket_shorts_publisher.py's data/counter.txt, so the two pipelines' numbering never interferes with each other. Without an explicit --output-template, --number-videos implies the naming template "{prefix} #{number}" (e.g. Clip #7.mp4), taking priority over --no-suffix/the default <name>_edited_... naming (same precedence --output-template itself already has - an explicit --output-template still wins over both). --number-start <N> only seeds the counter the very first time it's ever used (no counter file yet) - after that, changing it in the desktop/web settings panel resets the counter for real, immediately (see "manual counter reset" below), not by re-passing --number-start on the next run.
- Smart re-encode skip stays correct even with numbering active - the numbered output filename changes on every run (the counter always advances), so it can't itself be used to detect "this exact source file was already rendered". The skip check for a numbered run is instead keyed off a stable identity (source filename/orientation/container, independent of the number), so restarting a batch that already finished successfully now correctly skips everything instead of re-rendering and re-numbering the whole folder from scratch - which used to be the main way this counter drifted far ahead of where it should be after a batch was started/stopped a few times.
- The counter only advances past numbers that produced real output - a number is still reserved (and persisted immediately) before that file renders, so two files under --parallel can never race for the same number and a genuine process kill mid-batch can never hand out a number twice. But once the batch finishes (even with some files failing) persist_final_counter_value() corrects the saved counter down to one past the highest number that actually got a successful output file - a failed file's number is returned to the pool instead of being burned forever, as long as some later file in the same run didn't also succeed at a higher number (in which case that higher number is what's kept, to never collide with the file that already exists there). A batch where nothing at all succeeded rolls all the way back to where the run started. An abrupt external kill (e.g. the GUI's Stop button) still can't be corrected after the fact - the last number reserved before the kill stays burned, same as before.
- Manual counter reset - editing "מספר התחלתי"/"start number" in the desktop or web settings panel and moving focus away resets data/video_editor_counter.txt to that value immediately (utils/video/video_editor_counter.py's save_number(), called directly - not via the --number-start CLI flag, which is ignored once the counter file already exists), so a manual correction (e.g. after cleaning up a batch that numbered itself further than expected) takes effect right away instead of silently doing nothing until the counter file is deleted. Only fires when the typed value actually differs from what was there before, so unrelated settings edits/focus changes never reset it by accident. --validate-only runs reserve no number at all (nothing is actually being produced, so nothing should advance the persistent counter).
- Validate a batch's filter graphs before committing to a full render (--validate-only) - runs the exact real command (filters, watermark, HDR, LUT, everything) for each file but truncated to 0.3 seconds and muxed to null instead of a real output, reporting [VALID]/[INVALID] per file (and valid/invalid counts in the final RESULT: line) instead of rendering for real - catches a bad flag combination (a malformed LUT, an invalid crop expression) across an entire folder in seconds instead of discovering it after the first file of an hours-long batch fails. Ignores the smart re-encode skip and --force entirely - every file is checked regardless of whether it already has a rendered output.
- Per-file notifications (--notify-per-file) - sends a Windows toast (utils/shared/notifications.py's send_toast_notification()) and, if a Discord webhook URL is already configured (state.discord_webhook_url), a webhook message (send_webhook_notification()) for every file as it finishes (success or failure) instead of only one summary notification at the very end of the whole batch - useful for a long-running batch where you want to know as soon as a specific file is done, not just when everything is.
- Picture-in-picture overlay (--pip <path>, --pip-position (same choices as --watermark_position), --pip-scale (default 0.3 = 30% of output width), --pip-opacity) - composites a second video (not a static image, unlike the watermark) as a small layer on top of the main output, geometry computed by the same compute_watermark_geometry() the watermark uses (probing the PiP file's own dimensions via ffprobe/probe_video() instead of PIL.Image.open(), since it's a video, not a single frame). Builds as a second overlay stage in build_edit_filter_complex(), inserted before the watermark overlay when both are given (watermark - the branding layer - always ends up on top/last) - input indices are computed dynamically rather than hardcoded, since PiP always claims input index 1 (right after the main video) when present, pushing the watermark's own -i to index 2 instead of its previous fixed index 1. Not supported with --hwaccel (no proven CUDA equivalent in this project, same as chroma key/blurred background/stabilization above) - ignored with a log line, same pattern as those.
- Smart crop by face detection, as an alternative to motion-based smart crop (--smart-crop-face, requires --smart-crop already active) - utils/media/smart_crop.py's compute_horizontal_crop_offset_face() uses OpenCV's DNN-based cv2.FaceDetectorYN (the YuNet model, bundled at assets/face_detection_yunet_2023mar.onnx under Apache 2.0, since this project's installed opencv-python 5.x dropped the older cv2.CascadeClassifier/Haar-cascade API entirely - there's no cascade XML to fall back to even if one were bundled) instead of compute_horizontal_crop_offset()'s frame-diff motion analysis, weighting each detected face's x-position by the detector's own confidence score across ~15 sampled frames (fewer than motion's 40 - DNN inference per frame is considerably more expensive than a plain absdiff). Same -1.0..1.0 return contract and same "falls back to 0.0/centered on any failure, including a missing model file" guarantee as the motion-based function, so a talking-head clip with no clearly detected face degrades to a normal center crop rather than failing the file. Best suited to interview/talking-head footage, where the subject matters more than general on-screen motion.
- Editable per-run file queue (--only-files '["b.mp4", "a.mp4"]', a JSON array of filenames) - restricts (and reorders) which files get processed from the root of --input_dir for this one run, leaving any subfolders (find_video_files_by_folder()'s recursive groups) completely untouched - the queue editor described below only ever shows/edits the root folder's own files. A name in the list that doesn't match an actual video file there is silently ignored (not an error); invalid/non-JSON input logs a [WARNING] and falls back to processing every file in the root, same as not passing the flag at all.
- Export a run report to CSV/Excel (--export-report csv|xlsx|both) - writes a summary (run duration, average seconds/file, created/skipped/error counts, codec/container/hwaccel/parallel/effort) plus one row per file actually created (source/output filename, codec, container, resolution, fps, duration, output size in MB, that file's own render time, HDR) to workspace/reports/video_editor_report_<timestamp>.csv/.xlsx - a shared subfolder directly under workspace/, not nested under horizontal/vertical, since one report can describe a batch that touched either. The .xlsx is a real formatted workbook (utils/video/video_editor_report.py, reusing utils/shared/report_excel.py's style_sheet() - the same RTL/bold-header/auto-filter/freeze-panes styling already used for disk_scanner.py's Excel reports, not a one-off): a "סיכום" (summary) sheet and a "קבצים" (files) sheet. The .csv has the same two sections (summary key/value rows, then a blank line, then the files table) written with a UTF-8 BOM so Excel opens the Hebrew headers correctly instead of mangling them. Off by default; ignored together with --validate-only (nothing was actually rendered to report on).
uv run python my_scripts/video/video_editor.py --input_dir "D:\Clips" --watermark_path "D:\Clips\logo.png" --vertical --target-seconds 60 --codec hevc --hwaccel
uv run python my_scripts/video/video_editor.py --input_dir "D:\Clips" --remove_watermark_position bottom-right --watermark_path assets/new_logo.png --watermark_position bottom-right
uv run python my_scripts/video/video_editor.py --input_dir "D:\Clips" --stabilize --keep-audio --remove-mid-silence --auto-levels --lut assets/my_look.cube --codec hevc
uv run python my_scripts/video/video_editor.py --input_dir "D:\Clips" --preserve-resolution --no-suffix --parallel 3 --effort medium --codec hevc
uv run python my_scripts/video/video_editor.py --input_dir "D:\Clips" --lut assets/my_look.cube --stabilize --validate-only
uv run python my_scripts/video/video_editor.py --input_dir "D:\Clips" --lut assets/my_look.cube --stabilize --container mkv --output-template "{date}_{name}" --notify-per-file
workspace/reports/:
uv run python my_scripts/video/video_editor.py --input_dir "D:\Clips" --codec hevc --export-report xlsx
Episode #1.mp4, Episode #2.mp4, ...), continuing from wherever the last run left off:
uv run python my_scripts/video/video_editor.py --input_dir "D:\Clips" --number-videos --number-prefix "Episode"
web_app.py, see above): select video_editor.py in the sidebar and its settings show the watermark file picker (drag-and-drop or browse, desktop only), position, an opacity slider (0.0-1.0, with a live numeric readout; the desktop side also keeps a paired numeric entry field for precise typing), a "remove old watermark" position dropdown (defaulting to "none"), vertical/smart-crop/blur-background checkboxes, trim range + auto-trim-silence, timelapse seconds, keep-audio/captions (+ translate-to-English), loudnorm + custom LUFS target, denoise/sharpen, an FPS override field (blank = preserve each file's own source rate, the default - see above), LUT path/auto-levels/noise-gate/remove-mid-silence/stabilize, chroma key (toggle + key color + background color), caption styling (font/size/color/position), codec dropdown, a "preserve source resolution" checkbox, a "no output filename suffix" checkbox, a parallel-file-count field + effort dropdown (low/medium/high/auto), an optional max-concurrent-NVENC field (blank = auto-detect), an export-container dropdown (mp4/mkv), a custom output-filename-template field, a "sequential video numbering" checkbox + prefix field + optional start-number field, a "validate only" checkbox, a "notify per file" checkbox, and an export-report dropdown (off/CSV/Excel/both) - persisted in config.json on the desktop side (the web side resets to defaults on page reload, matching the existing fields' behavior - only the desktop side persists any of this panel today, aside from named profiles - see below), and included in profile snapshots on both sides (utils/video/video_editor_profiles.py's PROFILE_FIELDS). Both sides validate the typed FPS value with parse_fps_value() before adding --fps at all - an unparseable value (typo, stray text) is silently dropped rather than sent to the script, falling back to the same "preserve source" default as leaving the field blank. HDR handling has no field at all in either UI - it's fully automatic (detected per-file from the source, no flag to toggle), and its status for a given run shows up as an [INFO] line in the live console log, not as a form control. --remove_watermark_width_fraction/--remove_watermark_height_fraction keep their script defaults and aren't exposed as separate fields in either UI, the same way --watermark_scale for the new logo isn't either - the same "not every flag needs its own field" judgment call that still applies to --hwaccel/--nvenc-preset/--nvenc-cq/--target-size-mb/--force/--preview-only/--smooth-fps and to chroma key's --chromakey-similarity/--chromakey-blend fine-tuning knobs - none of these particular flags are in either UI, only reachable by running the script directly from a terminal. The desktop watermark preview reflects opacity for real (scales the actual alpha channel, matching colorchannelmixer=aa=X); the web preview approximates it with CSS opacity on the <img> tag instead, since it doesn't have a native way to composite alpha server-side for a live preview.
Personal profiles - beyond the four built-in, fixed presets above the watermark fields (a "type of edit": vertical/target-seconds/keep-audio/loudnorm/codec only, no paths, sourced from utils/video/video_editor.py's VIDEO_EDITOR_PRESETS), the same dropdown also lists profiles the user creates themselves (utils/video/video_editor_profiles.py, data/video_editor_profiles.json): a full snapshot of every field in the panel, watermark path/position/opacity included, saved under a name typed into the field next to "💾 שמור פרופיל"/"Save profile". Selecting one (marked "👤 " on desktop, since CTkOptionMenu has no <optgroup>; a separate "הפרופילים שלי"/"My profiles" group in the <select> on the web) fills in every field at once and reveals "🔄 עדכן"/"Update", "✏️ שנה שם"/"Rename", and "🗑️ מחק"/"Delete" buttons next to the dropdown. Unlike the rest of this panel, profiles are shared between both UIs through the same JSON file - one saved from the browser shows up in the desktop GUI's dropdown next time that panel is opened, and vice versa, no restart needed on either side.
- Update an existing profile in place (item) - "🔄 עדכן"/"Update" re-saves the currently selected profile with whatever the form holds right now, under the exact same name, with no retyping required. Both UIs already overwrote a profile if you saved a new one under a name that already existed (utils/video/video_editor_profiles.py's save_profile() always overwrites-if-exists, and the web's POST /api/video_editor_profiles never had a distinct "update" route to begin with - the JS updateVideoEditorProfile() just resends the identical save request with the already-selected name) - this button only makes that existing overwrite-by-name behavior reachable as an explicit one-click action instead of requiring you to retype the profile's name exactly to get the same effect. Hidden whenever no personal profile is currently selected (a built-in preset or the placeholder), same visibility rule as the existing delete button.
- Rename a profile (item) - "✏️ שנה שם"/"Rename" (utils/video/video_editor_profiles.py's rename_profile()) moves a profile's settings to a new name without retyping/re-saving every field. Desktop prompts via ctk.CTkInputDialog (no pre-filled default - the built-in dialog doesn't support one); the web uses the browser's native prompt() pre-filled with the current name instead, since that one does. Deliberately different from save_profile()'s silent-overwrite-by-same-name behavior above: renaming into a name already taken by a different profile always raises instead of silently merging the two - accidentally merging two unrelated profiles into one is a much more surprising failure mode than the "update a profile under its own name" case save_profile() already handles safely. Renaming into the profile's own current name (or cancelling the prompt) is a no-op, not an error.
- Folder saved as part of a profile (item) - a profile snapshot now also includes the currently-selected input folder (folder), restored along with every other field when that profile is selected. This one field deliberately breaks the otherwise-uniform video_editor_<field> naming convention every other field in PROFILE_FIELDS follows: the input folder (state.selected_gameplay_folder on desktop, state.folder on the web) is a single value shared by every script in the app, not something specific to the video editor, so both UIs collect/restore it as an explicit special case rather than through the generic per-field loop the rest of the panel uses. An older profile saved before this feature has no folder key at all and simply leaves the currently-selected folder untouched when loaded, rather than clearing it to blank.
- Confirmation after saving/updating a profile (item) - "💾 שמור פרופיל"/"Save profile" and "🔄 עדכן"/"Update" used to give no feedback beyond the dropdown/buttons appearing, easy to miss - both now show an explicit confirmation (messagebox.showinfo() on desktop, alert() on the web) naming the profile once the save actually succeeds.
- Duplicate a profile / "save as" (item, web-only) - "🔀 שמור בשם..." copies the currently-selected profile's settings under a new name and switches to it, leaving the source untouched (unlike "🔄 עדכן", which overwrites the current one). The copy starts with fresh usage stats (run_count=0) - it's a new profile's first use, not a continuation of the source's history.
- Export/import a single profile as JSON (item, web-only) - "⬇️ ייצוא" downloads one profile's settings as a standalone .json file (a clean PROFILE_FIELDS snapshot, no usage metadata - not meaningful on another machine/user); "⬆️ ייבוא פרופיל..." reads one back in, asking to confirm before overwriting a profile that already exists under the same name.
- Compare two profiles (item, web-only) - "🔍 השווה פרופילים..." shows only the fields that actually differ between two selected profiles (labeled with the same short terms as the glossary), not a full side-by-side dump of every field - useful with ~48 possible fields where most are usually identical.
- Last-used timestamp and run count per profile (item, web-only) - selecting a personal profile shows "🕒 בשימוש לאחרונה: record_profile_used()) when /api/run actually dispatches video_editor.py with that profile selected, not just when the profile is loaded into the form.
- Undo history for a profile (item, web-only) - every time an existing profile is overwritten (via "🔄 עדכן" or a same-name "💾 שמור פרופיל"), its previous settings are pushed onto a per-profile history (data/video_editor_profile_history.json, capped at the 5 most recent). "↩️ היסטוריה" lists past versions with a one-click "↩️ שחזר" - reverting itself pushes the pre-revert state onto the same history first, so a revert can be undone too.
- Explicit profile schema version (item, web-only) - every save now stamps the profile with the current PROFILE_FIELDS schema version (data/video_editor_profile_meta.json) - groundwork for cleanly migrating older saved profiles if fields are ever added/removed/renamed later, not a migration that exists yet (there's only been one version so far).
Settings organized into collapsible categories (item) - both panels' ~40 individual settings are grouped into 10 named sections (Profiles, Watermark, Format & Crop, Trim & Timing, Audio, Color & Visual Filters, Motion & FPS, Captions, Codec & Export, Resources & Infrastructure), each open by default and collapsible independently by clicking its header. On the web this is native <summary>/<details open> (the browser handles open/closed state and the disclosure marker for free, styled to match the rest of the panel); on desktop, CTkFrame/CTkOptionMenu etc. have no built-in accordion widget, so ui/settings_tab.py's _build_collapsible_category() builds one by hand (a clickable header row toggling a content frame's pack()/pack_forget(), with a ▾/▸ chevron reflecting state) - same open-by-default, independently-collapsible behavior on both sides. Purely a layout change: every field keeps its existing id/variable/handler, this only changes which container it's grouped under, so nothing about how a setting is read, saved, or validated changed.
A personal "⭐ Favorites" box, per profile (item) - a first, always-visible category (before "Profiles") that shows a live copy of whichever settings you've picked as favorites, so the ones you touch most don't require opening/scrolling through 10 categories every time. "⭐ הגדר מועדפים..."/"Configure favorites..." (next to the profile controls) opens a checklist covering all ~47 favoritable fields (everything except watermark_path/the drag-only watermark_x/watermark_y, and the shared input-folder field, which already have their own dedicated UI); the selection is a CSV of field names (video_editor_favorite_fields, same storage pattern as the existing favorite_scripts sidebar list) saved as part of the profile itself - favorite_fields in PROFILE_FIELDS - so different profiles can favorite a different subset. The favorites-box copy of a field is a genuine second control wired to the same underlying value, not a read-only mirror or a moved original - the field still shows (and is still fully editable) in its own category too, exactly as confirmed: changing either one updates the other immediately. On the web, this falls out mostly for free: the mirror is a real cloned <input>/<select>, and a small generic two-way sync (syncMirror()) copies the value and re-dispatches the original's own change/input event in either direction, so whatever side-effect the original field already had (e.g. updateWatermarkPreview()) still fires no matter which copy you edited. On desktop, entry/switch/slider fields get this for free too, in the other direction - CustomTkinter widgets bound to the same ctk.Variable sync natively with zero extra code - but CTkOptionMenu-based fields (codec, watermark position, caption position, effort, container, export format) have no such binding, so their handlers (_on_codec_selected() etc.) explicitly update both the original menu and the favorites-box mirror on every change.
Short terms instead of run-on sentences, hover explanations, and a searchable glossary (item) - field labels used to double as their own documentation ("מגבלת NVENC מקבילים (ריק=אוטומטי, רלוונטי עם --parallel+hevc/av1):"), which made them read as full sentences instead of a scannable name for the setting. Every field in the editor now shows a short 2-4 word term instead, with the explanation moved to a hover tooltip (native title= on the web; a small custom Tooltip popup on desktop, since Tkinter has no built-in one) - and also collected in one place behind a new "📖 מקראה"/"Glossary" button, a searchable dialog listing every term with its explanation for when you're not sure which field to look at in the first place. utils/video/video_editor_field_help.py's FIELD_HELP dict ({field_key: (term, tooltip)}) is the single source for all three surfaces on both UIs - desktop imports it directly; the web serves it as JSON from GET /api/video_editor/field_help for the glossary dialog (the visible labels/tooltips themselves are still static HTML, generated from the same source, so there's no fetch-race before a tooltip is usable) - so a term never drifts between the category it lives in and the favorites/glossary copies of it.
Unsaved-changes indicator and a confirm-before-switching-profile prompt (item) - editing a field after loading a named profile now shows "● שינויים לא-שמורים"/"Unsaved changes" next to the profile picker (compares the live form against a snapshot captured whenever a profile is loaded or saved/updated - cheap enough to just re-check on a periodic ~1-1.5s refresh instead of wiring a change-listener to 45+ fields individually). Picking a different profile (or a built-in preset) while dirty now asks for confirmation first instead of silently discarding the edit - declining reverts the picker to what was actually still loaded. Scoped to named personal profiles only; built-in presets have no "saved state" of their own to protect.
Category summaries and per-category color-coding (item) - a collapsed (or just visually scanned) category now shows a small "3 מתגים פעילים · 2 שדות מלאים"/"3 switches on · 2 fields filled" hint next to its title (counting on toggles and non-empty text/number fields within it; dropdowns and sliders are excluded since they always carry some value, which would make the count meaningless), and each of the 11 categories gets a fixed accent color (a left/right border on the card) so they're easier to tell apart at a glance - both refresh on the same periodic tick as the unsaved-changes check above.
A floating action bar, one-click reset to defaults, and manual favorites reordering (item) - Save/Update and the unsaved-changes indicator now also sit in a small bar pinned above the (internally-scrolling) settings panel, so they don't require scrolling back up to the "Profiles" category first. "↺ אפס לברירת מחדל"/"Reset to defaults" (next to it, behind a confirmation) resets every field to video_editor.py's own defaults in one click without touching any saved profile - it reuses the existing "apply a profile dict, missing keys fall back to defaults" code path with an empty dict rather than a second maintained list of defaults. Favorites can now be reordered with ▲/▼ buttons on each row (real drag-and-drop was judged more fragile than it's worth here, on both a Tkinter target and a touch-unfriendly desktop-mouse-only implementation on the web) - order is just the stored CSV's order, so moving a row is a swap-and-resave.
Live x/y percentages while dragging the watermark, and a one-time onboarding tip (item) - the existing drag-to-place watermark preview now shows the exact x=NN% y=NN% center position next to it while dragging (and after loading a profile with a custom position already set), for fine-tuning by eye and by number instead of eyeballing the drag alone. A dismissible "👋" tip banner (favorites / drag-to-place / the new glossary button) shows the first time the panel is opened - persisted server-side (video_editor_onboarding_seen in config) on desktop, and per-browser (localStorage) on the web, since it's a one-off UI hint rather than a setting worth syncing across devices.
Mobile-width layout for the web editor (item) - the panel's .ve-rows (label + control side-by-side) now stack vertically under 768px, with inputs/selects expanding to full width instead of their fixed pixel widths, and the floating action bar switches to full-width stacked buttons - the existing general mobile breakpoint covered the three-column page layout but not this panel's own internal rows, which stayed cramped and hard to tap. Caught mid-review: the panel's flex-direction: column (for the internal scroll area) was missing an explicit flex-wrap: nowrap, so once the panel's content grew past its max-height it silently wrapped into side-by-side columns instead of scrolling - a latent bug from the categories/favorites batch, only actually triggered once this batch's floating bar and onboarding banner pushed a normal-length profile over that height in practice.
The desktop GUI is frozen as of 19.9.2026 - all further UI/UX work on the video editor panel (and the rest of the app) goes into the web app only. An explicit, standing decision, not an oversight: two structurally different UI toolkits (a raw-HTML/JS string in web_app.py vs. a CustomTkinter widget tree in ui/settings_tab.py) doubled the cost of every panel change and was a real, repeated source of bugs this project already hit (Hebrew word-order mismatches from the desktop-only t() bidi workaround, a confirm-before-switch bug that only existed on one platform, etc.) - see the items below, none of which touch ui/settings_tab.py/gui_manager.pyw at all. The desktop app keeps running and gets bug/crash fixes if something breaks, but no new features. Two things stay desktop-only by nature, not by choice - the system tray icon and the global "Clip That!" hotkey are OS-level integrations a browser tab fundamentally can't provide - everything else described from here on is web-only.
Real drag-and-drop for reordering favorites, now that desktop no longer has to match it (item) - the ▲/▼ buttons from the categories/favorites batch stay (keyboard/touch-friendly fallback), but each favorites row is now also natively draggable, with dragstart/dragover/drop handlers reordering the same CSV-backed field-order (reorderFavoriteBeforeTarget()) the buttons already used - previously scoped down to buttons-only specifically because a Tkinter equivalent would've meant hand-rolling drag-and-drop on a toolkit with no native support for it, which was judged not worth it for a reorder list. Freezing the desktop removed that constraint.
- The same freed-up reasoning is why picture-in-picture and face-detection smart crop (above) shipped as CLI flags with a web UI, but no desktop fields at all - previously declined specifically for needing a second platform to also carry them.
A "PiP" category and a face-detection option next to smart crop, in the web editor (item) - --pip/--pip-position/--pip-scale/--pip-opacity (above) get their own collapsible category (between Watermark and Format & Crop, its own accent color), mirroring the watermark fields' layout (path/position dropdown/scale+opacity sliders). --smart-crop-face shows as an indented "↳ לפי זיהוי-פנים" checkbox directly under "קרופ חכם"/"Smart crop", disabled until smart crop itself is checked (mirroring the CLI's own "requires --smart-crop" gating, not just leaving a confusing no-op checkbox enabled).
An editable, reorderable file queue before running (item) - a new "📋 תור עיבוד"/"Processing queue" category lists the video files in the currently-selected root folder (GET /api/browse, filtered to the same five extensions find_video_files_by_folder() recognizes) with a checkbox per file and the same native drag-and-drop as the favorites list above, defaulting to every file selected in alphabetical order - i.e. today's exact behavior until you actually touch it. Only sends --only-files at all once the queue's actually been edited (a dedicated dirty flag, not just "does the order/selection differ from default") - so an untouched queue behaves exactly as if this feature didn't exist, including correctly picking up a file that was added to the folder after the queue was last refreshed. Deselecting every file is refused with an explicit alert rather than silently falling back to "process everything," which --only-files receiving an empty list would otherwise be indistinguishable from at the CLI level.
The actual command that will run, shown before you run it (item) - a new "⌨️ הצג פקודה"/"Show command" button in the floating action bar sends the current form to a new POST /api/video_editor/preview_command, which calls the exact same _build_extra_args() the real /api/run uses and returns the resulting flags joined into a copyable command line - not a parallel JS re-implementation of the flag-building logic that could quietly drift from what actually runs.
Live render progress inside the editor panel itself (item) - the floating action bar now shows a live "⏳ NN% · ETA" readout while a render is in progress, fed by the exact same /ws "progress" messages the page's main progress bar already consumed - no new channel, just an additional read of a message that was already arriving. Only shown while the video editor panel is actually the visible one, so it doesn't appear for other scripts' runs.
An estimated render time before you commit to a run (item) - a new utils/job_history.get_video_editor_eta_estimate(codec, hwaccel) averages seconds-per-file from past successful runs that used the exact same codec+hwaccel combination (the same per-file-division principle as the existing "time saved with --hwaccel" metric, just bucketed differently, and returning None rather than a misleading cross-codec average when there's no matching history yet), exposed as GET /api/video_editor/eta?codec=&hwaccel=&file_count=. The floating action bar shows ⏱️ ~<time> (N ריצות קודמות)/(N past runs) next to the codec dropdown, multiplied by however many files are actually queued up (the edited queue's selection count if it's been touched, otherwise 1 - a plain per-file estimate, not a batch-total guess).
Per-field reset, undo/redo, and a few smaller refinements (item) - a small "↺" next to (almost) every field resets just that one field, using values captured once from the page's own HTML-authored defaults on load (_captureVeFieldDefaults()) rather than a second Python-side defaults list that could drift from the real ones; a handful of fields that share one compact row with another (caption font+size, chromakey's two colors, etc.) get one shared button rather than one each, a minor cosmetic gap in the generic per-row implementation, not a missing feature. Ctrl+Z/Ctrl+Y undo/redo an in-session stack of full-form snapshots (JSON, capped at 50, debounced 400ms per edit) - separate entirely from the profile-level undo idea still on the roadmap, this is zero-cost and per-session, with no saved profile required; suppressed while focus is on a plain text/number field so the browser's own native text-undo isn't hijacked. A "🔀 שונה מההרצה האחרונה"/"Changed since last run" badge compares the live form against whatever was actually last sent to /api/run (not the saved-profile baseline, a different comparison) as soon as it diverges. "⏬ הרחב הכל"/"⏫ כווץ הכל" toggles every category open/closed at once, flipping direction based on whichever state most categories are currently in. The last profile/preset picked in the <select> is remembered per-browser (localStorage) and reselected automatically on the next page load. Closing the tab with unsaved changes now warns first (beforeunload), the same state.veBaseline check the existing unsaved-changes indicator already used, just also wired to actually leaving the page. The glossary's search box now supports arrow-key navigation and Enter to jump straight to a term, and clicking any glossary entry that has a real on-screen field closes the dialog, opens that field's category, scrolls to it, and briefly highlights it (veScrollToField()) - also reachable directly via a #ve-<category> URL hash for linking someone straight to a specific section. A handful of free-text fields (output template, LUT/watermark/PiP paths, chroma-key colors) remember your last 5 distinct values per field as a native <datalist>, entirely in localStorage - a typing convenience, not a setting.
Fully automatic favorites, replacing manual curation (item) - the "⭐ הגדר מועדפים..."/"Configure favorites..." picker dialog is gone entirely; favorites are now derived live from the form itself, with the same two rules the request specified: a checkbox field joins the moment it's checked and leaves the moment it's unchecked, and every other field type (text/number/select/range) joins the moment its value differs from the default captured at page load (the same _veFieldDefaults source the per-field reset button already used) and leaves the moment it matches that default again exactly - immediately, on every input/change event in the panel, not just at save time. A field's position in the favorites box is preserved once it's there (including any manual drag-reorder) - a newly-qualifying field is appended at the end rather than the whole list being recomputed from scratch, so reordering work doesn't get thrown away every time something else toggles. Loading a saved profile recomputes membership from its restored field values rather than blindly trusting the profile's stored favorite_fields order, but still uses that stored order as the starting point for fields that still qualify - a manually-arranged favorites order survives a save/reload round-trip. Desktop keeps its existing manual picker untouched (frozen, per the web-only policy above) - this is a deliberate, permanent divergence between the two UIs, not a temporary gap.
Auto-favorites now respect field dependencies (item) - watermark_position/watermark_opacity/watermark_scale (and the equivalent PiP trio) have no effect at all while their own path field (watermark_path/pip_path) is empty, so clearing the path now also drops them out of favorites even if their own value still differs from its default - previously only the path field itself left favorites, leaving inert position/opacity/scale settings pinned there. A small VE_FAVORITE_DEPENDS_ON map (fieldKey → the id of the path field it's gated by) is checked in _computeAutoFavoriteKeys() after the normal checked/changed-from-default rule; the fields' actual values are untouched, only their favorite-membership.
"Watermark" renamed to its Hebrew translation in the UI (item) - the category header and every related field label (path/position/opacity/scale/removal-position) now read "סימן מים (Watermark)" instead of the English word alone, in web_app.py's HTML/VE_FAVORITABLE_FIELDS labels and in utils/video/video_editor_field_help.py's shared FIELD_HELP terms/tooltips (imported by both UIs) - the single removal-position term is shortened to "מחיקת סימן מים (Watermark)" to stay under the glossary's enforced 30-character short-term limit. CLI flag names (--watermark_path etc.) and internal identifiers are untouched - this is a display-label translation, not a rename of the setting itself.
Faster timelapse rendering for long recordings (item) - investigated a report that --target-seconds (timelapse) could take hours on long gameplay recordings. Root cause: setpts=PTS/<factor> (the speed-up) ran through the entire per-frame chain (scale, watermark overlay, sharpen, LUT) before the final fps= filter dropped the ~95%+ of frames a high speed-up factor discards - fps= only depends on frame timing (PTS), not content, so reordering it to run immediately after setpts (before scale/watermark/sharpen/LUT) produces byte-identical output while skipping that per-frame work for the frames about to be dropped anyway. Guarded to only apply when nothing temporally-sensitive is also active (denoise/stabilize/silence-removal/smooth_fps genuinely need frames in their original decoded order/spacing; chromakey/blur_background build a separate filter graph) and never with --hwaccel. Measured on a synthetic 1080p→540p scale+sharpen timelapse at speed-factor 9: 4.47s → 2.09s (~2.1x) for the filter+encode portion. Decode time itself (the actual dominant cost for hours of source footage) is unaffected by filter order and unchanged by this fix - see the roadmap for the larger, separate idea of decoupling hardware-accelerated decode from the NVENC-only --hwaccel path, which would address that remaining piece.
Fade-in/fade-out (item) - --fade-in-seconds/--fade-out-seconds apply ffmpeg's fade/afade filters as the very last stage on both video and audio, after watermark/PiP compositing (so branding layers also darken/lighten, not just the base frame). Fade-out needs the actual output duration (post-trim/timelapse) to compute when it starts - st=duration-fade_out_seconds - so it's silently skipped if that isn't available.
Static text overlay (item: --text/--text-position/--text-font/--text-font-size/--text-color) - burns a fixed caption/lower-third into every frame, applied right before any fade so the text also fades with the rest of the frame. Different from --captions (auto-transcribed, changes over time) - this is one static string for the whole clip. Implemented via drawtext's textfile= (the text is written to a small temp file, one per run, not embedded inline in the filter string) with expansion=none - found in practice that embedding arbitrary user text directly into the filter_complex string required an unintuitive escaping+quoting combination that still silently corrupted a ' when combined with a : in the same string, and drawtext's own %{...} expression syntax broke on a raw % unless expansion is explicitly disabled; textfile= sidesteps all of that since the text content never touches the filtergraph's own parser at all.
Replace audio track (item: --replace-audio <path>) - mutes the original audio entirely and maps a separate audio/video file's audio stream in its place (not mixed). Automatically enables --keep-audio if not already set (replacing audio you're not keeping in the output would be a no-op). The replacement file becomes an additional ffmpeg input, positioned after any watermark/PiP inputs - the exact index is computed per-file so it stays correct across every input combination.
Cut multiple ranges from one clip (item: --remove-ranges, a JSON array of [start,end] pairs in seconds) - reuses the existing --remove-mid-silence mechanism (select/aselect with a synchronized video+audio boolean condition) rather than adding a second one - user-specified ranges simply merge into the same removal list as auto-detected silence, so both can be used together. Mutually exclusive with --trim-start/--trim-duration/--trim-silence, same reasoning as --remove-mid-silence: those all mean "pick one continuous sub-range," this means "remove several scattered ones."
Per-file flag overrides for a whole batch run (item: --batch-overrides <path-to-json>, {"filename.mp4": {"watermark_path": "...", "trim_start": 5}, ...}) - deliberately scoped to watermark_path/watermark_position/watermark_opacity/watermark_scale/trim_start/trim_duration only (the roadmap's own example), not a fully generic per-file override of all ~50 settings. Implemented as explicit function parameters resolved to local variables at the top of process_video(), not a temporary global-variable monkey-patch - --parallel runs files on real separate threads, so mutating shared module-level globals per-file would have been a genuine data race between concurrently-rendering files.
Hardware-accelerated decode without requiring NVENC output (item: --hwaccel-decode) - the existing --hwaccel bundles GPU decode and NVENC encode together (requires --codec hevc/av1); this is decode-only, works with any codec including the default h264 (CPU). Frames come off the GPU (hwdownload,format=nv12) as the very first filter stage, before anything else runs - from that point on the rest of the chain is byte-for-byte the same CPU path as without --hwaccel-decode at all, so it composes safely with every other filter (watermark, LUT, chromakey, etc.) without needing CUDA-native equivalents for each one. Falls back silently on HDR sources (same 8-bit-only limitation as --hwaccel, and tone-mapping needs the full decoded bit depth to work correctly) and is ignored if --hwaccel is already active. Measured on real NVIDIA hardware (RTX 4070 SUPER) with a 90-second 1080p synthetic clip + --sharpen: 40.6s → 18.1s (~2.2x) for the decode+filter+encode portion - decode is frequently the dominant cost for long recordings, and this now lets you keep it while staying on h264 for compatibility.
Stitch multiple clips into one with crossfade/dissolve transitions (item: --concat-files, a JSON array of filenames/paths, ordered) - a deliberately separate run mode (run_concat_mode(), bypassing the normal per-file folder loop entirely) rather than a new flag on the existing per-file pipeline, since "merge N inputs into one output" doesn't fit a script built around "one input → one output" at all. Every clip is scaled to common output dimensions first (--vertical picks horizontal/vertical, same as the regular path) since xfade/concat both require matching dimensions across inputs. --concat-transition fade (default - "crossfade"/"dissolve" are the same thing, just two names for it) chains xfade (video) and acrossfade (audio) across all clips, with each transition's offset computed from the running merged-duration-so-far minus the transition length (compute_xfade_offsets(), a small pure function kept separate specifically so the offset math has its own unit tests independent of filter-string construction); --concat-transition none uses plain concat instead, no overlap. Verified against real ffmpeg with solid-color test clips and pixel-sampled crossfade timing (a two-color 50/50 blend appeared at exactly the computed transition midpoint, not approximately). Deliberately narrow in scope: no watermark/LUT/trim/etc. on the clips being merged - it's a "stitch these together" operation, not a full per-clip editing pass combined with concatenation.
The web super-editor upgrade program (102 items, 24-25.9.2026)¶
The browser editor panel (web_app.py) and video_editor.py were upgraded in nine waves. Highlights, with the CLI flags each adds (every flag also has a panel field, a profile field and a glossary entry in utils/video/video_editor_field_help.py, with English terms in utils/video/video_editor_field_help_en.py):
- Safety + CLI parity - rotating profile backups and a recycle bin; every renderer flag reachable from the web panel; concat mode and per-file overrides inside the queue.
- Organization - workflow-ordered categories (accordion / two columns / tabs), progressive disclosure of dependent fields, live warning rules, global search, Ctrl+K palette, summary card, drag-to-reorder categories.
- Inputs - file pickers with live path validation, color pickers, system-font picker,
mm:sstime fields, a trim slider bound to the first file, a visual editor for cut ranges. - Preview & feedback - real preview frames through the renderer itself (
--preview-frame, before/after), static + ffmpeg validation, size/duration estimates, a timeline, drag-to-select logo removal (--remove-watermark-region), silence tuning (--silence-threshold-db,--silence-min-duration), run manifests (workspace/.run_manifests) with a results panel and "re-run only the failed files". - Queue - rich rows (thumbnail/duration/resolution/HDR), bulk actions, drag files in from the OS, last-run status, filters,
--no-subfolders, queue saved with a profile. - Profiles - tags/colors/notes, sorting and quick chips, a default profile, field diffs between versions and "merge field", export/import all, QR/link sharing, parent profiles (inheritance).
- Editing & content - aspect ratios and fit (
--aspect,--custom-width/--custom-height,--fit,--pad-color),--dual-output,--resolution,--scale-algo,--speed,--rotate/--flip,--deinterlace, color (--eq-*,--lut-intensity,--denoise-level,--sharpen-level,--tonemap),--crf,--audio-track; captions with styles/language/model and SRT/VTT export (--caption-style,--captions-language,--captions-model,--captions-export,--captions-no-burn), and--caption-max-charsto wrap burned captions at the last space before a character limit (never inside a word; exported SRT/VTT stay unwrapped), plus a transcript editor whose edited SRT replaces Whisper (--captions-srt-dir); timed text/image/timer/timestamp/file-name layers with fade/typewriter/slide (--layers-json,--timestamp-overlay; afilenamelayer burns the name of the source file being rendered),--progress-bar, a colored frame (--frame-thickness,--frame-color, and--frame-radiusto round its inner corners),--vignette,--ken-burns, a moving watermark (--watermark-move-seconds), PiP commentary audio with ducking (--pip-audio,--pip-duck- not background music), intro/outro bumpers,--boomerang,--loop-count, metadata and chapters (--meta-title,--meta-comment,--chapters), GIF/WebP/audio-only exports (--export-gif,--export-audio), and "detect highlights" (loud-moment detection filling the cut ranges,utils/video/highlight_detect.py). - Runs & automation - per-file pause/resume/cancel and "start next" while a run is in progress (
--control-file,utils/video/run_control.py), recent runs with load/re-run (utils/video/editor_runs.py), finish actions (open folder / sound / sleep with a cancellable countdown), run-then-publish to Rocket Shorts (only this run's outputs, optional dry run), schedule a run from the editor, and "always validate before running". - Quality - aria names for every control and slider values, dialog roles, mobile bottom sheets, an English label toggle, lazy-loaded sections, debounced favorites, one consistent in-page confirm dialog, and Playwright browser tests for every wave (
tests/studio/test_web_app_editor_wave*.py).
Wave 10 - reorganization, performance and preview (26.9.2026). The panel was restructured to remove duplicate settings and make it faster to work with:
- Layout - a top bar (profile picker, split 💾 save, profiles dialog, search, simple/advanced, "only changed", ▶ run with a mode menu: normal / re-render / validate / contact sheet), a fixed side pane with 📁 files and 👁 preview, seven workflow areas (📐 output & shape, ✂️ time, 🎨 look, 🧩 layers, 🔊 audio & captions, 📦 export, ⚙️ performance) and a 🕘 runs drawer. Profile management and the transcript editor moved into their own dialogs.
- No more duplicate fields - one control per decision (format chips, fill mode, resolution, silence removal, speed/timelapse, denoise/sharpen levels, audio source, quality, GPU). They write the same hidden legacy fields, so profiles, the CLI and warning rules are unchanged. Old profiles are migrated on load (static text and the timestamp become layers; a corner logo-removal becomes a region).
- Simple / advanced - simple mode hides rarely-used rows (
data-adv) and shows a hint when a hidden setting is active; "only changed" shows just the rows that differ from the defaults. - Preview - a 3/5/8-second video clip with sound through the exact render chain (
--preview-clip-seconds, served fromworkspace/.previewsby name only), with the before/after divider working on the playing video too; a draggable divider (mouse/touch/keyboard); safe-zone overlays for YouTube Shorts / TikTok / Reels / YouTube that warn when a logo or layer sits under the app UI; drag handles on the frame for the logo, the PiP (--pip-x/--pip-y, new) and every layer (layerposition: "custom"withx/ycentre fractions, new). - Timeline - draggable trim edges (also arrow keys), Shift+drag to mark a range to remove, 🔥 markers on detected highlights, auto-load when the preview file changes.
- Hybrid GPU pipeline - when colour/text/layers/rotation force filtering onto the CPU, deinterlacing (
bwdif_cuda), rotation/flip (transpose_cuda) and an early downscale to the output size (scale_cuda) now run on the GPU before the frames are downloaded, so the CPU filters work on output-size frames instead of 4K ones. Each step falls back to the CPU if that CUDA filter is missing; the early downscale is skipped when a filter works in source coordinates (logo removal, stabilization, chroma key, blurred background). Measured on an RTX 4070 SUPER (4K→1080p, HEVC NVENC, colour + vignette): ×1.41 plain, ×1.43 with a 90° rotation, ×2.28 with deinterlacing - same output (PSNR ≈44 dB against the old path).--no-gpu-hybridturns it off.
Waves 11-14 - professional-grade structure and tools (26.9.2026).
- Architecture (wave 11) -
web_app.pywent from 15,045 lines to a ~1,800-line composition root. The page lives in real files undersrc/entrypoints/web/frontend/(HTML by region, CSS, and JS by domain, assembled in the order offrontend/parts.txtinto the identical page -CLIPFORGE_DEV_RELOAD_PAGE=1re-assembles on every request), and the ~150 endpoints moved into FastAPI routers by domain undersrc/entrypoints/web/routes/. Shared state stays inweb_appand routers reach it asW.<name>, so there is one source of truth and existing monkeypatches keep working. Every JS file must parse on its own (tests/web/test_web_frontend_structure.py). - Data model v2 (wave 12) -
utils/video/editor_model.pyis the single definition of where each setting belongs: level (recipe / target / brand / project / run) and screen area. Profiles are still stored flat (the frozen desktop app, the CLI and scheduled tasks read them), but old profiles are migrated server-side on read, and exports can be structured by area. - Recipe vs. clip edit - the time area edits either all files or only the selected clip (trim, cut ranges, and layers that belong to one file). Clip edits become per-file overrides (
--batch-overridesnow also takesremove_rangesandlayers; trim + cuts compile into ranges). - Targets - YouTube Shorts / TikTok / Reels / YouTube / YouTube 4K / square / Discord (10MB) / archive. A run is recipe × targets: one queued run per target, each into its own output subfolder (
--output-subdir), with finish actions only after the last and "then publish" only for the Shorts target. - Brand kits (
utils/video/brand_kits.py,data/brand_kits.json) - logo, caption style, colors, intro/outro and layer templates, linked to recipes and applied at run time. - Projects (
utils/video/editor_projects.py,data/editor_projects/) - each folder autosaves its recipe snapshot, targets, clip edits, queue order and brand kit, and reopening the folder restores them; projects can be exported/imported (.clipforge.json). - Studio (wave 13) - a full-window focus mode with a three-column layout (files | player + docked timeline | inspector), a live player that plays the source and draws the edit on top in real time (fill mode, rotation, color, logo, PiP, timed layers, progress bar, frame, vignette, sample caption, fades, trims and removed ranges, speed), a 480p proxy for files the browser can't play, and a "📋 Jobs" home screen listing every project with its last run.
- Pro tools (wave 14) - timeline zoom, frame/second stepping, J/K/L and I/O keys, snapping to scene cuts, silence edges and highlights; keyframes for layers (position, opacity and text size over time, linear or eased, compiled to ffmpeg expressions); scopes (histogram, waveform, vectorscope) and a LUFS loudness graph (
ebur128); failures explained in plain language with one-click fixes (utils/video/editor_errors.py); and a render queue with reordering, "to the front", pause, "render tonight" and waiting while the computer is busy (a listed game/stream process, or general GPU or NVENC load over a threshold).
Waves 15-18 - design overhaul (26.9.2026).
- Design system (wave 15) - every color, size, spacing, radius, shadow and motion value comes from tokens in
frontend/styles/00-tokens.css(dark, light, and a "Pro Dark" set for the studio). All CSS lives in cascade layers (reset → tokens → components → shell → editor → studio → utilities), so later layers win without!important. The component library (12-components.css: buttons, fields, switches, badges, status pills, cards, dialogs, menus, toasts, empty states) replaced 55 raw colors and 270 inlinestyle="..."attributes (now utility classes in90-utilities.css), and layout uses logical properties (inline-start/end) so RTL and LTR both work./styleguideshows every token and component from the real CSS.tests/web/test_web_design_system.pyblocks new raw colors, pixel font sizes, undefinedvar(--x), physical left/right, unlayered CSS and new inline styles. - Typography, icons and brand (wave 16) - Heebo (Hebrew/Latin) and JetBrains Mono (logs, times, paths) are self-hosted variable fonts under
assets/fonts(OFL, served from/assets/fonts/), with one type scale instead of 12 ad-hoc sizes. The ~860 emoji used as icons render as monochrome Lucide SVG icons (ISC) that take the text color:dev/build_ui_icons.pyturns the locallucide-reacticons into CSS masks (02-icons.css) and an emoji map (05-ui-icon-map.js), and06-ui-core.jswraps each emoji in<span class="ei">- the emoji stays in the text, so search, copy and tests are unchanged. A real brand mark replaces the emoji favicon. - App shell (wave 17) - a slim top bar (brand, Ctrl+K search, status, icon toolbar) and a vertical navigation rail (bottom bar on mobile) replace the header and 9 tabs; a home screen (quick actions, recent projects, recent runs) replaces the empty console; the run history is an activity feed with human summaries ("24 created · 2 errors · 4:05") -
/api/historynow also returns the structuredresult; the log is a collapsible drawer; Ctrl+K also navigates screens, projects and system actions; andalert()messages are non-blocking toasts. - Pro studio (wave 18) - full screen hides the app chrome and uses the Pro Dark palette, with one run control (pause moved into the editor bar); workspaces (edit / color / audio) open just the relevant areas; the three columns resize by dragging (remembered); numeric settings change by dragging their label (Shift = faster, Alt = finer); the inspector uses a property grid with switches and styled sliders; the timeline has video / layers / audio tracks (layer bars mapped from output time to source time); target chips show their aspect ratio; and the player has an empty state that follows the folder.
Waves 19-21 - screens, polish, themes and English (26.9.2026).
- Screens (wave 19) - the jobs screen has search, status (running / completed / failed / not run yet) and target filters, a count, and a thumbnail from each project's first video (
list_projects()now returnsfirst_file), with Open and Run buttons on each card (Run loads the saved project and runs it). The results dialog opens with stat tiles (created, failed, skipped, total time, average per file, output size). Gallery cards preview the clip on hover. Clip approval has large Approve/Reject buttons and A/R/↑/↓ keys. The settings forms (bot, schedule, Rocket Shorts, general) use one field component with a fixed label and inline validation: webhook and RSS URLs, Discord IDs, times and hotkeys are checked on blur, and "Add" won't run while a field is invalid. Statistics show number tiles and canvas graphs drawn from the tokens. Every dialog gets a ✕ button, closes on Esc, keeps Tab focus inside, and returns focus to the button that opened it. - Polish (wave 20) - skeleton loaders replace the 11 "טוען..." placeholders; a thin progress bar at the top of the window follows the run on every screen; recent runs show their status as a colored edge; a comfortable/compact density toggle (📏) applies to the whole app; an undo panel (⋯ → 🕘 change history) lists every settings change in the session with a short description ("Speed: 1.5 · Codec: hevc"), and clicking any point jumps back or forward to it; and a 4-step guided tour runs once on first open (and from Ctrl+K). Entry animations follow
prefers-reduced-motion. - Themes, English and accessibility (wave 21) - the theme button opens a menu: auto (follows the OS, including
prefers-contrast: more), dark, light and a high-contrast theme (black background, white text, visible borders, yellow focus ring), five accent colors (cyan, emerald, violet, amber, rose, with darker variants in the light theme), and the interface language. English switches the page to LTR and translates the whole UI fromsrc/entrypoints/web/i18n/en.json(served by/api/ui_strings/en): 1,600+ strings keyed by the Hebrew text itself, plus patterns for dynamic text such as counts and relative times. AMutationObservertranslates text added later, the editor's own English field terms (item 100) are applied first, and switching back restores the exact Hebrew.tests/web/test_web_accessibility.pychecks that every static Hebrew string has a translation, computes WCAG contrast from the tokens for every theme and accent (text at least 4.5:1, focus ring at least 3:1, high contrast at least 7:1; a few light-theme colors were darkened to pass), and holds a CSS budget (230 KB, no@import). Focus rings are uniform, icon buttons are at least 34px (44px on touch screens), and long lists (activity feed, gallery, jobs, approvals) usecontent-visibility: auto.tests/web/test_web_app_wave19_21_browser.pyincludes a visual regression test:/styleguidein each theme, downscaled to grayscale and compared withtests/visual_baselines/(setCLIPFORGE_UPDATE_VISUAL_BASELINES=1to accept an intentional design change).
Wave 22 - stability and safety (27.9.2026).
- One owner for the editor's async flow -
frontend/js/07-ui-flow.jsaddsveFlow, which owns the folder → project → profile → queue sequence. A profile's saved queue beats the folder project's queue whichever response arrives first, and only the latest queue refresh applies its file list (this is the class of bug CI caught in waves 19-21). Lifecycle hooks are events onuiBus(ve:folder-changed,ve:fields-applied,ve:derived-refresh,ve:results-shown) and Ctrl+K entries come fromuiPaletteProviders, instead of files wrapping each other's functions. A handler that throws no longer breaks the rest of the chain. The wrapper count went from 66 to 57, andtests/web/test_web_frontend_safety.pyonly lets it go down. - Latest request wins -
uiLatest(key)cancels the previous request with the same key (AbortController) and ignores a response that comes back after a newer one. It's used by the timeline and waveform, timeline zoom strip, loudness graph, settings check, size estimate, queue metadata and last-run badges, ETA, profile info, profile comparison and the jobs screen. Before this, switching files quickly could show the previous file's timeline. - XSS hardening - every value that goes into
innerHTMLpasses throughuiEscape(), and rows built from names (encrypted backups, API keys, profile options, history) are built as DOM withtextContentand real event handlers. The safety test fails on any unescaped value in HTML and on anyonclick="...${...}"handler built from data. File names can come from outside (Twitch clip titles), error details from external services, and profile names from share links. - In-page prompts - the 11 native
prompt()calls (profile names, brand kit, layer template, "render tonight" time, backup password) are nowvePrompt(), a styled dialog that is translated, closes on Esc/✕, keeps focus, and has a password mode that clears the field on close.
Wave 23 - fast, stable tests and CI (27.9.2026).
- Parallel CI -
.github/workflows/ci.ymlruns three jobs at once: lint (compile sweep, ruff, mypy, pip-audit), unit (everything except the real-browser tests, in parallel withpytest-xdistand coverage) and browser (Playwright + Edge, two workers).tests/conftest.pymarks real-browser testsbrowserautomatically and puts each test file in its own xdist group, except files that render into the sharedworkspace/folder, which share one group so they never overwrite each other's output. Locally,uv run pytest tests/ -m "not browser" -n auto --dist loadgrouptakes about 5 minutes, and the browser tests about 2 minutes with-n 2. - Wait for a condition, not a timer - blind sleeps in tests became waits on the actual state:
uiPendingWork()(debounced editor work still pending, from the newuiDebounce), polling for the render lock's waiters, the worker's active processes and run files, and Playwright'swait_for_function.tests/infra/test_test_hygiene.pycapswait_for_timeoutcalls at 8 and only lets the number go down. - Translation coverage for dynamic text -
tests/web/test_web_i18n_dynamic.pypulls every string and template literal out of the frontend JS (skipping comments and regexes, reading nested templates), runs each one through a Python copy of the page's translator (exact match, colon variant, separators, patterns) and fails on any Hebrew left untranslated. 95 exact strings and 141 patterns were added (1,761 and 243 in total) so the English UI has no Hebrew left in toasts, confirmations, statuses and labels written at runtime. The translator now splits a string on separators before trying patterns and tries full-sentence patterns before partial ones. Relative times read "לפני שעה" / "אתמול" instead of "לפני 1 שע׳" / "לפני 1 ימים". - No logic keyed on UI text - the English UI exposed three places that compared translated text: layer rows looked up their fields by Hebrew
title, the bot button checked its Hebrew label to know whether the bot was running, and the studio's pause mirror read the pause button's text. They now usedata-field,data-runningand the real_pausedstate, and a test fails on any selector or comparison against Hebrew UI text. - Visual regression for real screens -
tests/web/test_web_app_visual_screens_browser.pyscreenshots the home screen, jobs, statistics and the studio in the dark and light themes and compares them withtests/visual_baselines/. The data is fixed: a wrappedfetchreturns fake projects, runs, history, hardware and render stats (the browser session's server is shared with other test files, and favorites and recent scripts come from the machine), with times relative to now. On failure the full screenshots are saved to.cache/visual_failures/and CI uploads them. SetCLIPFORGE_UPDATE_VISUAL_BASELINES=1after an intentional design change.
Wave 24 - performance (27.9.2026).
- CSS and JS as cached static files - the page used to inline all its CSS and JS, so every load sent about 1 MB. Now
web/frontend_assembly.pybuilds two bundles fromparts.txt,/static/app.<hash>.cssand/static/app.<hash>.js. The name is a hash of the content, so the browser caches them for good (immutable) and a code change always gets a new name. The bundles and the HTML are gzipped once at startup. A repeat load now downloads only the HTML (about 34 KB compressed instead of about 1 MB), and a first load about 245 KB. The HTML itself isno-cache, so it always points at the current bundles, and an unknown or old hash returns 404 rather than different content under a cached name.CLIPFORGE_DEV_RELOAD_PAGE=1still rebuilds everything on each request. Tests that check page content read the HTML plus both bundles (tests/page_source.py) orassemble_inline_page(), and the size budget checks the HTML, the JS and the compressed total separately. - A bounded pool for the server's own ffmpeg calls - thumbnails, probes, source frames, the timeline strip, previews and settings checks, the player proxy, and scene and loudness analysis used to call
subprocess.rundirectly from the routes, so a folder of hundreds of files could start dozens of ffmpeg processes at once, in the middle of a render.utils/video/media_pool.pyruns them in three lanes with their own limits: thumbnails and probes (2-4, by CPU count), interactive work the user is waiting for (2) and background analysis (2). A flood of thumbnails can't hold up a preview. Two requests for the same thumbnail or timeline strip (from the queue, the jobs screen and the home screen) run ffmpeg once, and the second waits for the file instead of reading it half-written. - The queue updates only what changed - checking a box or clicking a row used to rebuild every row of the queue (hundreds of rows with a thumbnail and event listeners each). Each row is now kept per queue item with a signature of what it shows. Only rows whose signature changed are rebuilt, in place, a rebuilt row keeps its existing thumbnail, and rows move only when the order actually changed.
tests/web/test_web_app_wave24_browser.pychecks this in a 300-file queue, and that the bundles come from the browser cache on the next load.
Wave 25 - pro color and audio tools (27.9.2026).
- Color wheels (lift / gamma / gain) - three wheels in the Look area (advanced mode) tint the shadows, midtones and highlights: drag picks the hue, the distance from the center is the strength (Shift for fine moves, arrow keys and Delete from the keyboard, double-click to reset), and a slider under each wheel brightens or darkens only that range. Each wheel is stored as an
R,G,Boffset (--grade-shadows,--grade-midtones,--grade-highlights, -1 to 1) that you can also type, and renders with ffmpeg'scolorbalance. - Curves - a curve editor for the master tone and for R, G and B separately (click adds a point, drag moves it, double-click deletes it), plus ffmpeg's curve presets (lighter, darker, four contrast levels, vintage, cross process, color negative). Points you set override the preset on the same channel. Stored as
x/ypoint lists (--curves-master,--curves-red,--curves-green,--curves-blue,--curves-preset). The grade runs right after brightness/contrast/saturation, as CPU filters, so with the full GPU pipeline filtering moves to the CPU the same way it does for the other color settings. - The live player shows the grade - the wheels and curves are previewed in real time through an SVG filter with a per-channel color table computed the same way as
colorbalanceandcurves. Without a grade no filter is applied. - Audio processing - in the Sound area: a low cut (
--audio-highpass, 0 = off, 80 Hz suits speech), a three-band EQ (--audio-eq-lowaround 120 Hz,--audio-eq-midwith--audio-eq-mid-freq,--audio-eq-higharound 8 kHz, in dB), a compressor with gentle / voice / strong settings (--audio-compressor) and noise reduction (--audio-denoise, light / medium / strong) with ffmpeg'safftdn. If you point--audio-denoise-modelat an RNNoise model file (.rnnn), the neuralarnndnfilter tuned for speech is used instead - the project doesn't ship a model, so download one yourself (for example from the RNNoise models collection) if you want it. The chain runs low cut → noise reduction → EQ → compressor, after speed changes and before the noise gate and loudness normalization, and only when the output keeps audio (the editor warns otherwise). - Checks and translation -
utils/video/color_audio.pybuilds all the filters and parses the wheel and curve values; the same parser flags a bad value next to the field before a run, and ffmpeg errors fromcurves,colorbalance,arnndnor the audio filters point back to the right field. All the new controls are in English mode too.tests/studio/test_color_audio.pyruns every filter through the real ffmpeg and renders a clip with everything on;tests/web/test_web_app_wave25_browser.pydrags the wheels, draws curves, restores them from a profile and checks the live preview.
Wave 26 - B-roll inside the timeline (27.9.2026).
- A second clip for a time range - the layers editor has a new 🎞️ B-roll layer: a video clip that plays inside the output from a start time to an end time (output time, like every layer), from a chosen point in the clip. It can fill the frame (cropped to fit), fit inside it (with borders) or sit in a window (PiP) with its own size and position, which you can also drag on the preview. Without an end it runs until the clip or the output ends, whichever comes first, so the output keeps its length. Concatenating clips back to back was already possible; this cuts away to other footage and back.
- Its audio - muted (the main audio continues underneath), mixed with the main audio, or instead of it for that range (the main audio is silenced in the range and comes back after). A clip without audio is simply not mixed.
- Rendering -
utils/video/video_editor_layers.pyseeks straight to the in-point and reads only the needed length (-ss/-ton the input), shifts the clip to its start time, scales it for the chosen framing and overlays it only inside its range (eof_action=pass); the audio is delayed to the same start and mixed after the rest of the audio chain (EQ, compressor, loudness). Layers still force CPU filtering with the full GPU pipeline, as before. - In the editor - the timeline's layers track shows the clip's name, the live player plays the B-roll muted and in sync with the output time (its audio is heard only in the render), and a missing clip is flagged next to the layers field before a run.
tests/studio/test_broll_layers.pyrenders a real clip and checks the B-roll frame appears only inside its range, the length and the audio;tests/web/test_web_app_wave26_browser.pycovers the editor row, the drag handle and the live player.
Wave 27 - a cleaner screen (2.10.2026).
- The top bar is one row - the folder, the profile, 💾 Save ▾, search, Simple/Advanced, ▶ Run ▾ and 🛑 Stop. Everything else moved into the three menus: Save ▾ now holds the profile manager, Run ▾ holds "check settings now", "estimate size and length", scheduling and "run tonight", and ⋯ holds full screen, "only what I changed" and the studio workspaces. ⏸ appears only while there is something to pause. Before this the bar had 13-17 buttons on two rows.
- The folder is a short chip - the bar shows only the folder's name. Its menu shows the full path, "choose folder...", "all jobs" and up to six recent folders (from the saved projects), and picking one opens it with its project. In the studio the app's long path row is hidden, since the chip replaces it.
- One status bar at the bottom - the output summary, the file count (shown once - it used to appear twice), the time estimate, the save state and the project state now sit in one line at the bottom of the editor. Warnings about setting combinations collapse into one "⚠️ N" button there that opens the list.
- Reset only where something changed - the ↺ next to every field (53 in advanced mode) is gone. A changed field is marked with a dot next to its label, and its ↺ shows on hover or focus, or from a right-click on the field ("↺ reset to default"). An area's ↺ shows only when the area has changes, and an area without changes no longer says "nothing is enabled here".
- The player shows a frame right away - loading a folder opens the live player paused on the first file, instead of an empty stage waiting for a click (turn off with the
veAutoFramepreference). - One preview switch - 🎥 Live / 🖼️ Frame / 🎬 Clip is one segmented control that shows the current mode. The player tools (drag handles, safe zones, scopes, auto-refresh) are small icons above the player's corner.
- Queue search and a floating bulk bar - the queue has a file search and a 🎚️ Filter button that folds the date/length/not-yet-processed filters away. The bulk actions (include, exclude, top, bottom, shared override) appear in a floating bar only when rows are selected, and "select all" selects only the rows the search shows.
tests/web/test_web_app_wave27_browser.pychecks the single row and the menus, the folder chip and recent folders, the status bar, the warnings button, right-click reset, the automatic frame, the preview switch and the queue search and bulk bar.
Wave 28 - an inspector you can find your way in (2.10.2026).
- Tabs in the studio - in full screen the settings are always tabbed: Output, Time, Color, Effects & cleanup, Layers, Sound, Export (and Performance in advanced mode), one area at a time instead of one long column (Look alone used to be almost two screens). Each tab shows how many settings changed in it. The old workspaces (all/edit/color/sound) are now just these tabs, so their buttons left the ⋯ menu;
setVeWorkspace()still works and switches tab. Outside the studio the layout you picked (accordion, columns or tabs) stays. - Look split in two - Color (brightness, contrast, saturation, LUT, HDR, wheels and curves) and Effects & cleanup (noise, sharpening, stabilization, vignette, Ken Burns, chroma key).
- Export split into sections - File (codec, quality, size, container, FPS), Name & numbering, Extra files (thumbnail, GIF/WebP, audio only) and Tags & chapters. Fades, intro/outro, boomerang and repeats moved to Time ("Fades, intro, outro and repeats") - they change the content, not the file.
- Sections fold into one line - every section title is a button (click or Enter) and a folded section shows a summary of what changed in it ("Saturation 1.4 · LUT film.cube", or "Default"). Main sections start open, the rest start folded unless something in them changed, and the editor remembers what you opened. Jumping to a field (from a warning, the search or a favorite) opens its section, and search still finds fields in folded sections.
- Dependent fields from the model -
utils/video/editor_model.pyhas aDEPENDS_ONtable (LUT strength needs a LUT, chroma-key colors need chroma key, numbering prefix/start need numbering, caption details need captions, progress-bar details need the bar, and so on). The page reads it from the schema and hides a field until its parent is on; when the field shares a row with its parent, only the field is hidden. - The schema describes the panel -
editor_model.AREASis the tab order, and the newSECTIONSlists each area's sections, their titles, which start open and their fields./api/video_editor/schemaserves areas with sections, each field's section and the dependencies.tests/studio/test_editor_model.pychecks that the page matches: the areas in order, and every visible field inside the section the schema names. - Uniform controls - labels sit in one fixed-width column so sliders and lists line up, sliders and lists fill the rest of the row, and the ⓘ next to a field is always faintly visible and opens the full glossary explanation instead of the short tooltip.
- One time control - every time field accepts seconds or mm:ss, steps by 0.1 s with the arrow keys (1 s with Shift), scrubs in tenths when you drag its label, turns 75 into 1:15 when you leave it, and has a ⌖ button that takes the player's current time.
- No duplicate format choices - the "both" format chip is gone (two targets do the same thing, each to its own folder). An old profile with dual output shows a note with "convert to targets" (YouTube + YouTube Shorts), and while targets are selected the format chips are locked, since the targets decide the format.
- A guard for the bundle - all the page's JS is one script, so a name declared twice at the top level breaks the whole page.
tests/web/test_web_frontend_safety.pynow fails on that before it ships.tests/web/test_web_app_wave28_browser.pycovers the tabs, the split areas, folding sections, dependent fields, the label column and glossary help, and the time control.
Wave 29 - the timeline and the player as the editing surface (2.10.2026).
- Edit right on the timeline - every layer is a bar on the timeline: drag its body to move it in time, drag either edge to change its start or end. A layer without an end stays open-ended if you only move it to the end. Ranges to remove can now be edited the same way: drag a red range to move it, drag its edges to change it, double-click it to delete it (Shift+drag still marks a new range, and the green trim edges work as before).
- A bigger timeline with fixed tracks - 🎞️ video (thumbnails), 🎬 B-roll, 🧩 layers and 🔊 audio (waveform), each B-roll clip on its own track. Drag the grip at the top of the timeline (or use the arrow keys on it) to make it taller or shorter; the height is remembered.
- Snapping - while dragging, a bar snaps to the playhead, to the edges of other layers, to the start and end of the output and to scene changes, silence edges and highlights (8 px, a dashed line shows the snap). Alt drags without snapping, and the 🧲 switch turns it off.
- Click an object to edit it - clicking a logo, PiP or layer on the player, a bar on the timeline or a row in the layers list selects it everywhere and opens its settings. Double-click the video track to jump to the trim settings, or the waveform for the silence settings. Escape clears the selection.
- A floating bar next to the selected object - on the player: ⚙️ settings, ⧉ duplicate, 👁 hide and 🗑 delete (remove for the logo/PiP), and a corner grip to resize by dragging (the logo/PiP scale, an image or B-roll window's scale, or a text layer's font size).
- A real layers list - at the top of the Layers tab, the top layer first: each layer with a name you can edit, 👁 hide (the layer stays in the project but isn't rendered), 🔒 lock (it doesn't move when dragged on the timeline), ⧉ duplicate, ✕ delete, and drag to reorder (higher in the list = drawn on top). The logo, PiP, progress bar, frame and old-logo removal are listed too.
- Drag files in - drop a video on the timeline to add it as B-roll at that time (5 s, near the start or end edge it becomes the intro or outro), an audio file to replace the soundtrack, an image on the player to make it the logo where you dropped it (or a sticker if there already is a logo), or a video on the player for PiP. A queue row dragged onto the timeline becomes B-roll from that file. The browser can't hand over a local file's path, so dropped files are copied to the project's assets folder next to the saved projects (
POST /api/video_editor/upload_asset), never into the input folder where they'd be rendered as inputs. - A fix found on the way - since wave 27 the live player opens paused as soon as a folder loads, so it could be left showing a frame that's no longer in the output (for example after moving the trim start). It now jumps to the next kept frame whenever the edit changes while it's paused.
tests/studio/test_editor_wave29.pychecks that a hidden layer isn't rendered and the asset upload (folder, no overwrite, types, path traversal);tests/web/test_web_app_wave29_browser.pydrags bars (move, resize, snap, locked), checks the synced selection, the layers list, the timeline height and dropping files on the timeline and the player.
Wave 30 - a simple working model and clear words (3.10.2026).
- One way to edit a single clip - ✏️ on a queue row, or "✏️ רק הקליפ הזה" next to the preview file, opens clip mode: an amber banner says "editing clip_3.mp4 only", the fields show that clip's values, and every change to the trim and ranges, the logo (file, position or drag, size, opacity) or the layers is kept for that clip only. A ✏️ mark next to each changed field puts it back to the value of all files; "↩ like all files" clears the clip's changes, "📋 apply to all files" turns them into the settings of every file, and "✓ done" leaves clip mode. Everything that can't be changed per clip is locked while the banner is up, layers shared by all files can't be moved or edited, and new layers belong to the clip. This replaces four separate mechanisms - the time scope switch (recipe/clip), the inline ✏️ overrides editor in each queue row (and its shared "override" for picked rows), the per-layer file picker as the only way to get a clip layer, and hand-built batch overrides. Queue rows show "✏️ N" for files with changes of their own. Projects saved before this keep their meaning (three empty time fields used to mean "no edit").
- Per-clip logo position in the renderer -
watermark_x/watermark_yare now allowed per-file overrides (utils/video/video_editor.py), andmy_scripts/video/video_editor.pyuses them as that file's logo center; an explicit corner for one file cancels the logo drag of all files.utils/editor_model.compile_clip_editpasses the logo fields through and leaves the time alone when only the logo changed. - Fewer concepts, the same words everywhere - the web UI now talks about three things: the project (folder + settings + queue, saved automatically), styles (saved settings, formerly "profiles", and their branding) and targets. "Jobs" became "projects", "brand kit" became "branding", "layer template" became "ready layer", "recipe" became "all files", and caption "style" became caption "design" so the word isn't used twice. Error messages from the style routes use the new word too (the frozen desktop app keeps its own wording). The English UI follows (Style, Projects, Branding).
- One save indicator - next to 💾 in the top bar: "✓ saved" for the project (the time is in the tooltip), "⏳" while saving, "⚠️" if it failed - and "● style "X" changed" with 🔄 update / 💾 save as new only when a saved style is selected and the settings really differ from it. The two separate indicators in the status bar ("project saved" and "unsaved changes in profile", often shown together) are gone.
- Ready looks with a preview - cards for natural, cinematic, vivid, black & white, vintage and warm at the top of the color settings: each shows the current frame of the player (or the file's thumbnail) in that look, using the same color math as the live player (brightness/contrast/saturation plus the color wheels and curves as an SVG filter per look). A click applies the look, the active card is marked, and moving a slider afterwards shows "custom".
- "Simple" is really simple - simple mode is now four big steps: 1 📁 folder (name and file count), 2 🎯 where the videos go (the target chips), 3 🎨 look (the look cards and your saved styles), 4 ▶ run ("2 files × 2 targets = 4 videos"). The settings areas, the queue and the timeline are in advanced; searching for a setting or jumping to one switches over. The player gets the rest of the screen.
- A fix found on the way - the "another render is running (PID, age)" banner got its age already formatted in Hebrew from the server, so the English UI showed "0ש'".
/api/lock_statusnow also returnsage_secondsand the browser formats it. tests/web/test_web_app_wave30_browser.pycovers clip mode (overrides, marks, locks, promote, revert, old projects), the save indicator, the look cards, the four steps and the new words;tests/studio/test_editor_model.pyandtests/studio/test_web_app_editor_wave1.pycover the logo overrides, andtests/studio/test_video_editor_advanced_e2e.pyrenders a logo moved for one clip only with real ffmpeg. The raw JS budget went from 750 KB to 790 KB; the gzipped budget is unchanged.
Wave 31 - running, results and comfort (8.10.2026).
- The editor always opens in the studio - there used to be two layouts of the same editor (inside the scripts page, and the full-window studio), two places to maintain and to wonder where you work. Now selecting the editor always opens the studio; "✕ close the editor" in the ⋯ menu goes back home. The settings can be shown in tabs (default) or all in a list. The old "two columns" layout and the drag-to-resize panel height (both only outside the studio) are gone - the latter also swallowed clicks on the status bar inside the studio.
- A "before running" sheet - ▶ run (top bar, simple step 4, Ctrl+Enter, Ctrl+K) first shows what will be rendered ("2 files × 2 targets = 4 videos", the targets, the mode, files with their own clip edits), the estimated time and size, and the warnings - each one jumps to its setting. ▶ run (or Ctrl+Enter again) starts it; "show before every run" can be turned off here or in the ▾ run menu.
- Results inside the studio - the run results replace the player instead of opening a dialog: a card per output file with its thumbnail (click plays it in place), "⇆ against the source" (source and output side by side, the source following the output's play/pause/seek) and 📂. The summary tiles, "rerun only the failed" (the main action when something failed) and the fix suggestions per failure stay. "✕ back to editing" returns to the player.
- Before/after in the live player - "⇆ before/after" in the player bar draws the source frame without color and look on the left of a draggable line, the result on the right (vignette included). Found on the way: the live player treated saturation 0 (black & white) as 1, so a black-and-white look never showed live.
- One shortcut map - ? opens a map built from one list (general, player and timeline, editing), and the player keys now work anywhere in the editor, not only when the timeline has focus: Space, J/K/L, I/O (trim at the current point), Delete (the selected layer). Ctrl+Z/Ctrl+Y also undo curve edits.
- Ctrl+K that does things - "codec hevc", "speed 1.5", "add logo", "look cinematic", "target tiktok" (Hebrew or English): the palette shows a ⚡ row on top that sets the field, adds the layer, applies the look or adds the target.
- Laptop screens - below 1440×800 (for example 1366×768) the file list folds into a 📁 rail with a count, the settings column is narrower, the preview header is compact, and the player fits with its controls (655×369 instead of 539×303 with the bar cut off). Both side panels can be folded into icon rails at any size; the settings rail has an icon per area.
- Visual hierarchy - inside the panels a regular button is now quiet (no border or fill until hover), and at most one action per area is emphasized; layer cards are sections, not cards inside cards.
- Useful empty states - no folder / no videos in the file list (with "choose folder" or "refresh" and the drop hint), no layers ("drag an image here or pick a ready layer", with text/image/logo buttons, and an image dropped on the list becomes a layer), no runs, and the timeline before a file is picked.
- Measured -
tests/web/test_web_app_wave31_tasks_browser.pyperforms five core tasks with real clicks and keeps a budget: trim 2, logo 3, color 2, vertical export 2, run 2 = 11 actions (12 in the wave 26 UI: color took 4 and run 1 - the run now adds the summary sheet). In simple mode: color 1, vertical 1, run 2. Each task finishes in well under a second. tests/web/test_web_app_wave31_browser.pycovers the sheet, results, before/after, shortcuts, the palette commands, the laptop rails, the empty states and the hierarchy (at most one emphasized button per area, 90% quiet buttons, boxes at most two deep). The CSS budget went from 230 KB to 240 KB (and 1,450 to 1,500 rules) after removing about 2 KB of dead CSS.
Wave 32 - from OBS to publishing, and SEO from Gemini (9.10.2026).
- The whole chain from OBS to publishing - the Super Streamer listeners (
--mode obsand--mode folder) still copy each new clip into Import, and now also print oneEVENT: {"type": "clip_ready", ...}line (utils/auto_chain.clip_ready_line).utils/shared/worker.pyhands such lines to the handler registered for the event type (TaskWorker.on_event) the moment they're printed - a listener never finishes, so aRESULT:line wouldn't do. The web server then does what "🔗 after a clip comes in" in the Rocket Shorts settings says: only copy (the default, as before), edit with a saved style, edit with a style and then publish (the existing "🚀 then publish" chain, Shorts target only), or publish right away with Rocket Shorts. Only the new clip is processed - the editor getsonly_files, and a direct publish gets a batch folder of its own, because Rocket Shorts processes the whole folder and Import also holds older clips. "🧪 simulation" runs the publish with--dry-run. Everything goes through the regular render queue (it waits for other renders and for a busy computer like any run), only events from the three listener scripts count, a missing style is reported instead of guessed, and the section shows what happened to the last clips (GET /api/auto_chain/recent). The planning is pure logic inutils/media/auto_chain.py; the settings areauto_chain,auto_chain_profileandauto_chain_dry_runindata/rocket_settings.json. - SEO from Gemini -
utils/bot/seo_generator.pyno longer writes one fixed sentence chosen by a keyword in the file name. It asks Gemini for a title, a one-line description and hashtags from frames of the clip (utils/title_generator.generate_metadata, the same key and call Rocket Shorts uses), merges them with the game's tags (no duplicates, up to 8) and writes a sheet per platform: the TikTok caption, the YouTube title (up to 100 characters) and the YouTube description. If Rocket Shorts already wrote<name>_ai_metadata.txtnext to the clip, that file is reused instead of calling Gemini again. With no key, with "🤖 SEO from Gemini" (seo_ai) off or on any failure, the old template is used, so an export never fails because of AI. In the gallery, TikTok export copies the caption to the clipboard before opening TikTok Studio, and the YouTube upload uses the Gemini title, description and tags (with the template it keeps the file name, the counter naming, as before). The SEO now runs in the thread pool instead of blocking the server for the seconds Gemini takes. - Game detection by whole words - "rl", "mc" and "gta" now count only as words of their own (
RL_save,mc-clip,gta5), soworld_tour,girloremceeare no longer detected as Rocket League or Minecraft, and the file name wins over the folder (Rocket League/minecraft_build.mp4is Minecraft). - A fix found on the way - choosing the same folder again did nothing, so a folder that was missing when the page loaded (an external drive that wasn't connected yet) stayed with no files and no count until you picked another folder and came back. With an empty queue, the same folder now loads again.
tests/bot/test_auto_chain.pycovers the planning, the event lines in the worker (also mid-run from a real script), the folder listener's event, the server chain (edit then publish, direct publish in its own batch, off or another script = nothing) and the settings;tests/bot/test_seo_generator.pycovers Gemini, the fallback, the setting, reusing the Rocket Shorts file and the whole-word detection;tests/web/test_web_app_wave32_browser.pycovers the settings section and picking the same folder again. Tests never call the real Gemini or write the real SEO log (tests/conftest.py).
Wave 33 - an inspector you can find your way in (15.10.2026).
- Where am I - a breadcrumb at the top of the settings panel: 📁 folder › 🎞️ file › ✏️ clip edit › area. Each part is a button: the folder opens the folder picker, the file is shown in the queue, "clip edit" leaves clip mode and the area scrolls to its top.
- Search and "only what I changed" in the panel itself - the setting search moved from the top bar into the panel it filters (/ still focuses it, and opens a folded panel first), next to a chip "✎ only what I changed (N)" with the number of settings that differ from the defaults.
- Settings that don't apply right now say why - PiP position, size, opacity and audio without a PiP file, the logo's position, size and opacity without a logo, the LUFS target without loudness normalization and the NVENC concurrency with one file at a time are dimmed with a 💤 line saying what turns them on. They stay editable (you can fill them in first), and the switch in the same row is never dimmed. Dependent fields shown dimmed (the "show dependent fields" preference) say which field they wait for.
- Warnings next to the field - every active warning (for example loudness normalization without audio) also shows under the field it is about, not only in the warnings box.
- Tools per group - each group title has ↺ (back to the defaults), 📋 copy and 📥 paste. A copy is kept in the browser, so it can be pasted into another project. Each group also has one line that says what it does.
- Help in place - a "?" next to every setting with an explanation opens the explanation under the field (works with touch, unlike the tooltip), with "📖 in the docs":
GET /api/docs/section?doc=video_editor&q=--flag|key|name(src/utils/shared/docs_sections.py) returns the docs paragraph that mentions the setting's flag, from an allowlist of docs only. - Each tab remembers where you were - switching tabs and back returns to the same scroll position.
tests/infra/test_docs_sections.pycovers the paragraph search (whole flags only, priority order, allowlist, long paragraphs) and the route;tests/studio/test_web_app_wave33_browser.pycovers the breadcrumb, the chip, dimmed settings, warnings by the field, the group tools and the help. The CSS budget went from 240 KB to 245 KB (1,520 to 1,560 rules), the raw JS from 815 KB to 840 KB and the gzipped total from 263 KB to 270 KB.
Wave 34 - levels, tabs, one library and the keyboard (15.10.2026).
- Three levels - "simple" (the four steps), "regular" and "expert" (the old "advanced", everything). "Regular" hides about 40 rare technical settings (
VE_RARE_FIELDSinjs/78-editor-modes.js: NVENC preset and concurrency, scaling algorithm, gamma, LUT strength, silence thresholds, GIF size...). A rare setting that was changed is always shown, a search shows everything, jumping to a rare setting switches to expert, and a chip "🧰 N more settings in expert mode" says how many are hidden. - Tabs in the order of the work - time, color, effects, layers, sound, output, export, performance (
utils/editor_model.AREASand the page follow the same order; time is the default tab). The last tab is remembered per project (folder). - One library - 📚 (next to the search, and in the ⋯ menu) shows styles (apply, or manage), branding (link to the style, save the current settings as branding), targets (add/remove) and the ready layers of every branding (add as a layer) in one window with tabs.
- One icon per concept - project 📁, style 🗂️, branding 🏷️, target 🎯, ready layer 📑.
tests/web/test_web_ui_terms.pychecks that a concept word after an icon uses its icon (action icons like ➕ or ⚠️ are fine) and that the old words (profile, brand kit, layer template, recipe, job) don't come back to the UI. - Color A/B - "📌 A/B" in the player bar keeps the current color as A; after changing the color every click switches between A and B with the live player, and "✕ A/B" ends the comparison with the look that is shown.
- Full screen player - ⛶ in the player bar puts the player (with its bar) in full screen.
- The keyboard between areas - F6 / Shift+F6 move to the next / previous area, Alt+1..4 jump to files, player, settings and the top bar.
- Shortcuts you can change - ✎ next to run, save, Ctrl+K, search, the shortcut map, play, delete and F6 in the shortcut map (?) records a new combination (Esc cancels, Backspace restores the default, a combination that is taken is refused). The old combination stops working; "↺ reset shortcuts" brings all back.
- Tour and what's new - "👋 editor tour" in the ⋯ menu explains each area in place (next / previous / end); "🆕 what's new" lists the last waves, with a dot on the ⋯ button until it's opened. Nothing opens by itself.
tests/studio/test_web_app_wave34_browser.pycovers the levels, the tab order and memory, the library, A/B, the area keys, a changed shortcut, the tour and what's new. The raw JS budget went from 840 KB to 870 KB, the gzipped total from 270 KB to 278 KB, the HTML from 200 KB to 210 KB and the CSS rules from 1,560 to 1,600.
Wave 35 - the queue and the timeline (15.10.2026).
- Grid, sort and groups in the queue - 🖼️ next to "select all" turns the file list into a grid of large pictures (remembered). "↕ sort by" sorts once by name, date, length, size, color tag or the status in the last run (the manual order with drag still works after it). Grouping (by day, color tag or status) sorts by the group and puts a small title above the first file of each group. The queue holds the files of the chosen folder only, so there is no grouping by subfolder.
- Color tags - the bar that appears when rows are picked has 🔴🟠🟡🟢🔵🟣 (and ⚪ to remove). Tags are kept in the browser per folder and shown on each row.
- Files that were already rendered lead to the output - the ✅ of the last run on a row is a button: it opens the results of that run and plays the card of that file.
- Previous / next file - ⏮ and ⏭ around the preview file picker step through the queue.
- Named markers - "📍 marker" in the timeline row (or M) adds a marker with a name at the chosen point. Markers are kept per file, show their name on the timeline, a click jumps to them and a double click deletes.
- Clip edit on the timeline - in clip mode the timeline gets an amber frame with "✏️ clip edit: name", not only the banner above the player.
- Minimap - when the timeline is zoomed, a thin bar above it shows the whole file, the kept part, the markers and the visible window; a click or a drag moves the view.
- A time you can type - the box next to the point on the timeline takes seconds or mm:ss and Enter moves the point there.
- Track names and whole-track lock - a double click on a track tag (video, B-roll, layers, sound) renames it, and 🔒/🔓 on the B-roll and layers tracks locks or unlocks every layer in that track.
tests/studio/test_web_app_wave35_browser.pycovers tags, grouping, sort and grid, the ✅ link, previous/next, markers (per file), the minimap, the typed time, the clip frame and the track lock - with a timeline that doesn't need ffmpeg. The CSS budget went from 245 KB to 250 KB, the raw JS from 870 KB to 890 KB and the gzipped total from 278 KB to 284 KB.
Wave 36 - layers, runs and export (15.10.2026).
- Layer groups - 📂 on a layer row puts it in a group (by name). The layers list shows a title above each group with 👁 / 🔒 for the whole group and ✕ to ungroup (the layers stay). The group is only a name on the layer; hiding and locking set the existing
hidden/lockedof each member, so the renderer needs nothing new (utils/video_editor_layers.sanitize_layersdrops the extra key). - Branding as a locked row - when the style is linked to a branding, the layers list starts with "🏷️ branding: name 🔒"; a click opens the branding tab of the library.
- Align and distribute - ⇤ ↔ ⇥ ⤒ ↕ ⤓ above the add bar move the selected layer to a side or the center (the other axis stays), and "⇹ distribute" spreads the layers shown at this moment evenly across. 📐 in the player tools draws guides (thirds and center).
- Built-in ready layers - the library's ready layers tab now starts with a lower third, a subscribe call, a countdown and date/time, each with a small preview (position, color, box), before the ones saved in a branding.
- Runs center - the runs drawer starts with three cards: what's queued or running (opens the queue), the history (the list below) and the latest results.
- Run names, tags and comparison - ✎ on a run gives it a name and #tags (
POST /api/video_editor/runs/{id}/label,utils/editor_runs.set_run_label, kept in the runs history). Marking two runs (⇆) and "compare" shows the folder, style, counts and duration and every setting that differs. - Schedule from the sheet - "⏰ schedule..." in the "before running" sheet opens the schedule dialog instead of running now.
- Open the project that made this - the results header has "📁 open the project that made this" when the results belong to another folder.
- Export templates - "📦 template" at the top of the export area saves and applies the file, naming and extra-outputs settings by name (kept in the browser).
- Units you can drag - more number fields show their unit (s, px, fps, LUFS), and dragging on a unit changes the number like dragging on a label (Shift = fast, Alt = fine).
tests/studio/test_editor_run_labels.pycovers the run names and tags and the route;tests/studio/test_web_app_wave36_browser.pycovers groups, align/distribute, guides, the branding row, built-in ready layers, the runs center and compare, scheduling from the sheet, opening the project, export templates and dragging units. The CSS budget went from 250 KB to 255 KB (1,600 to 1,660 rules), the raw JS from 890 KB to 910 KB and the gzipped total from 284 KB to 292 KB.
Wave 37 - one field schema and an architecture you can follow (15.10.2026).
- One field schema - a setting used to be defined in four or five places: the CLI flag (
utils/video/video_editor_cli.py), its area (utils/video/editor_model.py), the term and explanation (utils/video/video_editor_field_help.py), whether a style keeps it (PROFILE_FIELDS) and its registration in the browser.utils/video/editor_field_schema.pyjoins them into one record per setting (area, flag and alternative flags, type, default, choices, term, explanation, kept in a style), with the few settings whose flag has another name mapped explicitly (lut_path→--lut,include_subfolders→--no-subfolders...).GET /api/video_editor/field_schemaserves it: the "📖 in the docs" link now searches the real flag, and a setting without a tooltip gets the explanation from the schema (and its "?" button).tests/studio/test_editor_field_schema.pyfails when a setting registered in the browser is missing its flag, explanation or area. - A settings object for the renderer (step 1) -
utils/video_editor_settings.VideoEditorSettingsholds every setting of a run by its flag's name, read-only, withwith_overrides()for the settings of one clip (a copy - nothing shared changes, which matters with--parallel). The script builds it (SETTINGS) next toargs. Movingprocess_videofrom the ~150 module globals to this object is the next step, and it needs a real byte-for-byte render comparison first - that heavy check was deliberately left for later. - An area for every browser file and an internal API -
web/frontend_areas.jsonassigns each JS file to one area (core, shell, screens, editor fields, inspector, player and timeline, layers, runs), andjs/81-editor-api.jsdefines what each editor area offers the others (veApi.queue.sort,veApi.timeline.addMarker,veApi.layers.write,veApi.library.open,veApi.runs.history...). New cross-area code goes throughveApioruiBusevents.tests/web/test_web_frontend_areas.pychecks that every file has exactly one area and that every function the API calls exists. - A screen-load budget -
tests/studio/test_web_app_wave37_browser.pycounts the visible controls in the editor at each level: simple < regular < expert, regular hides at least 15 controls compared with expert, and simple stays under a cap. The counts are printed on every run, so the cap can be tightened later. - Also new:
tests/studio/test_video_editor_settings.py(defaults equal the parser for every flag, read-only, overrides copy).
Wave 38 - the graph editor: three areas and a live preview (15.10.2026).
- Three areas, like the studio - the data videos screen is now episodes | live preview | settings (one under the other on a narrow screen), instead of one long form. The outputs and "new episode from CSV" stay below.
- Episode cards - each card has a picture from the video (75% in), the chart type, size, when it last ran (and what), the status of its publishing package and an "edited settings" mark. A search box, sorting (last run first, title, folder name) and a chart-type filter sit above the list; "🗄️ show hidden" is a clear chip with the count, and hidden episodes are marked on their card.
- Live preview - a frame of the episode at any moment, with a slider. It follows the run's chart type, and while the settings panel is open it shows the unsaved title, subtitle, number of places, theme and chart. Frames are drawn on the server without rendering a video (
GET /api/dataviz/preview_frame,utils/dataviz_preview.py): the episode and its plan stay in memory by a fingerprint of the episode's files, every frame is also kept as a PNG underworkspace/.dataviz_previews(capped at 400), and a frame that was already drawn doesn't even rebuild the episode. - A strip of 10 frames along the video; a click picks that moment.
- Compare chart types / light vs dark - the same moment in all six chart types (a click picks it for the run) or in both themes side by side (a click puts it in the settings, to save).
- Length and render time before running - under "what to produce": the video's length, its frame count and a rough render time for the chosen mode (
GET /api/dataviz/estimate). - Files of its own, loaded on demand - the screen lives in
html/31-dataviz.html,styles/17-dataviz.css,js/42-dataviz-screen.jsand the newjs/43-dataviz-studio.js. Its JS (with the approvals tab'sjs/41-dataviz-packages.js) is no longer in the main bundle:frontend_assembly.LAZY_JSbuilds it as/static/dataviz.<hash>.js, anduiLoadLazy("dataviz")loads it the first time the screen or the approvals tab opens. The main JS went down from 910 KB to 875 KB; the budget test now measures what the first load ships and gives every on-demand bundle its own cap (120 KB). tests/dataviz/test_dataviz_preview.pycovers the frame (PNG, cache, start/end, themes), the estimate, the routes and unsaved settings;tests/web/test_web_frontend_structure.pychecks the on-demand bundle;tests/dataviz/test_web_app_wave38_browser.pycovers loading on demand, the three areas, cards, search/sort/filter, the slider and the strip, comparisons and the estimate.
Wave 39 - the graph editor: every setting, events, colors and entities (15.10.2026).
- One schema for an episode's settings -
dataviz/schema.pylists every setting you can edit from the browser (path, type, range, group, Hebrew label and explanation). The form on the screen, the check of every value that comes from the browser and a real JSON Schema ofconfig.yaml(GET /api/dataviz/config_schema) all come from it; a test checks every path exists in the engine's defaults. - Every setting, in groups - "🧰 all settings, events, colors and entities" below the basic settings: texts, pacing, ranking and axis, records, event cards, timeline, video, music and publishing - 50 settings instead of 8, each with its explanation. Every field shows the engine default, the value in the episode's file (📄) when there is one, and "✏️ changed here" with ↺ to go back to the file / default.
- A draft you see before saving - every change is a draft that shows right away in the live preview (
drafton/api/dataviz/preview_frame, checked like a save); typing an intro or outro text jumps the preview to the intro or the outro. "💾 save changes" writes them (POST /api/dataviz/episodes/override, field by field - set and unset), "discard draft" drops them. Saving the basic form no longer wipes these changes; only "reset to source" clears everything. - Events editor - a table of date and description (add, delete) and the same events as markers on the episode's date range; dragging a marker changes the date. Events read from
events.csvstay in the file - an edit is saved as its own list, and "↺ back to the episode file" returns to the CSV. - Colors - the theme's background, text and accent, the palette, and a fixed color per entity (new in the engine:
colors, entity -> #rrggbb, on top of the palette). - Entities: alias, merge, exclude - new in the engine:
entities.aliasesrenames an entity in the video, two entities with the same alias merge (their sums add up), andentities.excludehides an entity completely (dataviz/episode.apply_entity_rules). With no rules nothing changes - existing episodes render the same. - What changed - a list of everything the episode's changes file sets, next to the value in
config.yamlor the default, with ↺ per row. - The screen's CSS now comes with its on-demand bundle too (
frontend_assembly.LAZY_CSS, added as a<style>when the screen opens): the main CSS went down from 256 KB to 248 KB. tests/dataviz/test_dataviz_schema.pycovers the schema, value checks, the JSON Schema, the form rows, aliases/merge/exclude and fixed colors through the engine;tests/dataviz/test_dataviz_editor.pycovers field-level saving, refused values, events/colors/palette/entities, the basic form keeping advanced changes, the routes and drafts in the preview;tests/dataviz/test_web_app_wave39_browser.pycovers the editor in a browser.config.yamlis checked unchanged in every test.
Wave 40 - the graph editor: the data (15.10.2026).
- "📊 data and checks" in the settings area (
GET /api/dataviz/episodes/data?part=...,utils/dataviz_data.py): - Sources - every source with its type, column mapping (time and period, value, entity), legend, row counts (valid / dropped) and its first 20 rows.
- Check before running - days with no data at all and the longest such run, negative values, sparse entities (under 0.5% or fewer than 3 days) and rows that were dropped for a bad date. When something is worth fixing, "⚠️ N things to check in the data" appears next to ▶ run and opens the check.
- Cumulative - the running totals of the leading entities over time (a sample of dates and the last day), to see the numbers make sense.
- Pacing and timeline - a chart of frames per day: the base pace, the days where the STEP rule (no value moves more than
stepof the axis in one frame) slowed the video down, the slowdown windows around events, the events and the five biggest daily peaks. - Spreading - how a monthly or yearly total is spread over its days (with the weights file, when there is one).
- Update data - a new file with the same columns for a CSV source: first a table of totals before and after (and new / gone entities), and only after "✅ replace the data" it is saved - as a new file under
data/dataviz_overrides/<episode>/, with the episode's override pointing to it. The original file in the episode folder never changes. - Excel and JSON, and dropping a file - a new episode can start from Excel or JSON as well as CSV (
POST /api/dataviz/convert_tableturns it into CSV), and a file dropped anywhere on the screen opens "new episode" with its columns. - Music on the waveform - a "🎵 music" tab in the full editor: pick a music file from the episode folder (or none), and click on its waveform to choose where it starts; the part that plays under the video is highlighted, with a warning when the music ends before the video (
GET /api/dataviz/episodes/waveform- WAV is read directly, other formats through pydub). tests/dataviz/test_dataviz_data.pycovers sources, the check, cumulative totals, pacing, spreading, Excel/JSON, updating data (compare, then a new file only - the episode folder is checked unchanged), music and the routes;tests/dataviz/test_web_app_wave40_browser.pycovers the panel, the badge, updating data, dropping an Excel file and the waveform.
Wave 41 - the graph editor: series, outputs and publishing (15.10.2026).
- Targets - "target" next to ▶ run: YouTube (16:9, as the episode is set), YouTube Shorts or TikTok (a vertical 1080x1920 frame, with margins and the timeline laid out for portrait, written as
<name>_shorts.mp4/<name>_tiktok.mp4so it never replaces the horizontal video).dataviz_maker.py --target shorts;src/dataviz/targets.py. - A queue of episodes - a "queue" tick on every episode card; "▶ render N episodes in a row" sends them all in one run (
dataviz_maker.py --episodes a,b), one after another, with the same mode and target. - Duplicate and next in series - "⧉ duplicate episode" copies the folder under a new name (files and browser changes; the original is untouched, an existing name is refused), and "➕ next in series" also raises the series number. A series (
series: {name, number}in the config) shows as a badge on the card. - New episode from a template - "template" in the new-episode form: design, pacing, ranking, labels, outro, colors, logo and series (number + 1) come from an existing episode; title, data and events come from the new file. The value and group columns of a new file are guessed (the first numeric and the first text column), so a CSV turns into a running episode in three clicks.
- Channel kits - "🏷️ channel kit": a theme, colors, palette and a logo in a corner (
logo: {path, position, scale, opacity}), saved indata/dataviz_kits.json. A kit is made from an episode or imported from a super-editor brand kit (its watermark becomes the logo), and applying it to an episode writes to that episode's override file only. - Outputs - tick "⇆ compare" on two videos and play them side by side (the second follows the first); "🗑️ move to Recycle Bin" sends a video with its package, SEO sheet and thumbnail to the Windows Recycle Bin (restorable).
- Publishing inside the screen - "📦 publish this episode" lists the selected episode's packages, and the publish wizard walks through them: texts (YouTube title, description, TikTok caption, hashtags - editable until the package is sent,
POST /api/dataviz/packages/{o}/{n}/metadata), thumbnail, approval, and sending - dry run, Rocket Shorts, or a private YouTube upload with an optional scheduled publish time (YouTube makes it public by itself). A sent package shows a link to the video and the scheduled time. tests/dataviz/test_dataviz_series_publish.pycovers targets, the run arguments, duplicating, templates, kits, the Recycle Bin and package texts / scheduling;tests/dataviz/test_web_app_wave41_browser.pycovers the queue with a target, next in series, kits, compare and Recycle Bin, the wizard (dry run only), and the click budget from a CSV to a running episode.
Wave 42 - the graph editor: the engine (15.10.2026).
- Map chart (
map_race) - each region of a GeoJSON file in the episode folder (map: {geojson, key}) is colored by the cumulative value of the entity with the same name, with the ranking beside the map (src/dataviz/geo.py,charts/map_race.py). The data check lists entities that have no region on the map. It appears in the chart lists of the screen, and in the chart comparison only for an episode with a map file. - Captions -
captions.enabled(or--captions) writes<name>.srtnext to the video: one caption per event card and per "new record", at exactly the frames they are on screen (computed from the plan,src/dataviz/captions.py). "💬 captions (SRT)" downloads the file for the full video without rendering (GET /api/dataviz/episodes/captions). - Narration -
narration.enabled: a Windows voice (SAPI through PowerShell, no extra packages) reads each event when its card appears, in full and test videos; ffmpeg mixes the lines over the music without lowering it and copies the video stream. Voice, rate, volume and whether records are read too are settings; a failure keeps the video as it was (src/dataviz/narration.py,GET /api/dataviz/voices). - Picture and GIF for sharing - a new mode, "share": the strongest moment is the day with the biggest jump;
share.png(full size) and a shortshare.gifaround it (share.gif_seconds,gif_fps,gif_scale) are written to<name>_share/, without ffmpeg (src/dataviz/share.py). They show up with the outputs. - Engine guide - "📖 engine guide" on the screen: chart types, modes, targets, captions / narration / map / branding, every setting (label, explanation, range and default - built from the schema, so it never goes stale), the command line, and the Windows voices on this computer, with search (
src/dataviz/help.py,GET /api/dataviz/help). - Everything is off by default, so existing episodes render exactly as before.
tests/dataviz/test_dataviz_wave42.pycovers captions and SRT, narration (lines, fake WAVs, the ffmpeg mix and keeping the video on failure), the strongest moment and the share files, GeoJSON and the map chart, the data check, the guide and the routes;tests/dataviz/test_web_app_wave42_browser.pycovers the guide, share mode, the map in the chart lists and the captions download.
Wave 43 - the graph editor: look and data leftovers (16.10.2026).
- Contrast check - the colors tab checks the text (4.5:1), the accent color (3:1) and the palette / fixed entity colors (2:1 - bars and areas are large) against the background, and updates with every color change before saving.
- Font - a font picker in the colors tab (the installed fonts the engine knows,
GET /api/dataviz/fonts), saved astheme_overrides.fonts(names only, with fallbacks). - Intro / outro from a clip -
bumpers.intro/bumpers.outro(in "branding"): after the video is made (full and test), ffmpeg joins the channel clips before / after it, fitted to the video's size and frame rate, with silence for a clip without sound; the video is replaced only when joining succeeded, and the SRT captions move by the intro's length (src/dataviz/bumpers.py). - Channel kits now carry the font and the intro / outro clips too - saved from an episode, or imported from a super-editor brand kit (its caption font and intro / outro).
- Updating data works for Excel and JSON sources as well: the new data is saved as a new CSV file and the source switches to CSV with the same column mapping; the original file is untouched.
- The Recycle Bin also takes a "share" folder, and with a video also its
.srtand thumbnail candidates. tests/dataviz/test_dataviz_wave43.py(no real ffmpeg, no rendering) andtests/dataviz/test_web_app_wave43_browser.py.
Wave 44 - the graph editor: publishing and queue leftovers (16.10.2026).
- Publish wizard - "✨ suggestion from Gemini" in the texts step fills the YouTube title, description and hashtags (a suggestion only - "next" saves it;
POST /api/dataviz/packages/{o}/{n}/suggest), and "🖼️ 3 more options from the video" in the picture step makes three thumbnails (30% / 60% / 90% of the video, with the title) to choose from; the chosen one replaces the package thumbnail and the first original is kept as<name>_thumb_original.jpg. - Views - a package that was uploaded to YouTube has "👁 views" on its card: views, likes and comments are fetched only on that click (
POST .../stats, through the existingyoutube_analytics.get_video_stats), kept in the package, and shown on the episode card too. - Episode queue - a run of several episodes shows a row per episode (waiting / running with its percentage / done / removed / failed) and "✕" removes an episode from the queue - a running one stops (its partial video is deleted) and the queue goes on. The script writes
data/dataviz_queue.jsonand reads the skips fromdata/dataviz_queue.control.json(--queue-file;GET /api/dataviz/queue,POST /api/dataviz/queue/skip). tests/dataviz/test_dataviz_wave44.py(ffmpeg, Gemini, YouTube and rendering are replaced) andtests/dataviz/test_web_app_wave44_browser.py.
Wave 45 - the graph editor: engine leftovers (16.10.2026).
- Before / after in the engine guide - every number, choice and yes/no setting has "👁 before / after": two frames of the selected episode at the current moment, with the default and with another value (a draft only - nothing is saved).
- Recorded narration - a "🎙 narration" tab in the full editor records your own voice for each event from the microphone; every recording is a new file under
data/dataviz_overrides/<episode>/narration/<date>_<time>.webm(the newest wins, "🗑" sends one to the Recycle Bin), and the episode points to the folder (narration.folder). When narrating, a recorded event plays its recording; the others use the Windows voice, unlessnarration.ttsis off. A recording can also be named after the event's date or label (src/dataviz/narration.py). - Maps - polygons keep their holes, and the projection is Mercator by default (
map.projection:mercatororequirect). A "🗺 map" tab holds a map library (data/dataviz_maps/- a GeoJSON is uploaded once and never overwritten): choose the property with the area name (with sample values), check which of the episode's entities are on the map, and use the map in the episode (src/utils/dataviz_library.py). tests/dataviz/test_dataviz_wave45.py(no rendering, no sound) andtests/dataviz/test_web_app_wave45_browser.py.
Wave 46 - ready maps (16.10.2026).
- The map tab lists ready maps with their size and license: Israel's 6 districts and 15 subdistricts (geoBoundaries, public domain / CC0) and the world's countries (Natural Earth 1:110m, public domain). "⬇ download to the library" fetches the file only on that click, only from the fixed address in the catalog (
READY_MAPSinsrc/utils/dataviz_library.py, at most 5MB), checks it is a GeoJSON of areas and saves it as a new file indata/dataviz_maps/(GET/POST /api/dataviz/maps/ready). Area names in these maps are in English, so Hebrew entities need an alias with the map's name.
Wave 47 - the remaining open items (16.10.2026).
- City boundaries / shapefiles - the map library also takes a zipped shapefile (
.shp+.dbf, with.cpgwhen present - Hebrew in cp1255 works without it): polygons and holes are read without extra libraries and saved as a new GeoJSON map (POST /api/dataviz/maps/upload_shapefile,dataviz.geo.shapefile_to_geojson,utils/dataviz_library.save_shapefile_zip; at most 30MB zipped / 120MB unzipped). Coordinates in metres (Israel's grid, as in the Ministry of the Interior's municipal-boundary files) are drawn as they are. The municipal boundaries themselves are not in the open data catalog - they come from the gov.il boundaries service as a zipped SHP. - Queue grouped by folder - in the video editor, "group by folder" puts the folder's own files under "main folder" and lists every subfolder with videos as a group under the queue (subfolders are always processed in full, so they are shown, not picked); with "include subfolders" off it says they will not be rendered.
- English for server texts - the graph editor's setting labels, explanations and choices, and the whole engine guide, come in English when the interface is in English (
?lang=en;src/dataviz/i18n_en.py, with a test that every setting has its English text). Default values that are the video's own content (for example the outro's place names) stay as they are. tests/dataviz/test_dataviz_wave47.py(a shapefile built in memory, no downloads) andtests/dataviz/test_web_app_wave47_browser.py.
Wave 48 - process_video on a settings object (16.10.2026).
process_video(my_scripts/video/video_editor.py) no longer reads the ~85 run globals: everything it uses comes fromcfg, aRenderConfigthat holds the values derived from the flags (built once asRENDER_CONFIG, after all of them are set, together withSETTINGSandHWACCEL_REQUESTED). A single file can get other settings withcfg=RENDER_CONFIG.replace(...)without touching the shared globals, which is safe under--parallel. The helper functions it calls are unchanged.- Proof without rendering - "golden commands" (
tests/video_editor_golden.py): the script is loaded in-process with 48 flag combinations (codecs and NVENC, GPU paths, HDR passthrough / tone-mapping, logo positions and dragging, trims and ranges, speed and timelapse, aspect / fit / dual output, rotation, colour and LUT, stabilize, chroma key, text, layers, PiP and audio, two-pass size targets, validate-only, preview frame, per-file clip edits), probing / detection / encoding are faked, and the ffmpeg commands it builds are compared withtests/data/video_editor_golden_commands.json, recorded from the code before the change. Identical commands mean identical videos. A deliberate command change is recorded withuv run python -m tests.video_editor_golden --write.