Skip to content

Commit e46fa89

Browse files
fix(compositor): address review findings #1-#4, #7
- Replace dimension-matching cache heuristic with index-based mapping using image_overlay_cfg_indices (finding #1) - Only update x/y position on cache hit, not full rect clone (finding #2) - Fix MIME sniffing comment wording to 'base64-encoded magic bytes', add BMP detection (finding #3) - Switch from data-URI to URL.createObjectURL with cleanup for image overlay thumbnails (finding #4) - Change SAFETY comment to Invariant in prescale_rgba (finding #7) Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
1 parent 7094175 commit e46fa89

File tree

3 files changed

+74
-47
lines changed

3 files changed

+74
-47
lines changed

crates/nodes/src/video/compositor/mod.rs

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,16 @@ impl ProcessorNode for CompositorNode {
253253

254254
// Decode image overlays (once). Wrap in Arc so per-frame clones
255255
// into the work item are cheap reference-count bumps.
256+
//
257+
// `image_overlay_cfg_indices` records, for each successfully decoded
258+
// overlay, the index of the originating `ImageOverlayConfig` in
259+
// `config.image_overlays`. This allows the cache in
260+
// `apply_update_params` to map decoded bitmaps back to their configs
261+
// without relying on dimension-matching heuristics.
256262
let mut image_overlays_vec: Vec<Arc<DecodedOverlay>> =
257263
Vec::with_capacity(self.config.image_overlays.len());
264+
let mut image_overlay_cfg_indices: Vec<usize> =
265+
Vec::with_capacity(self.config.image_overlays.len());
258266
for (i, img_cfg) in self.config.image_overlays.iter().enumerate() {
259267
match decode_image_overlay(img_cfg) {
260268
Ok(overlay) => {
@@ -269,6 +277,7 @@ impl ProcessorNode for CompositorNode {
269277
overlay.rect.height,
270278
);
271279
image_overlays_vec.push(Arc::new(overlay));
280+
image_overlay_cfg_indices.push(i);
272281
},
273282
Err(e) => {
274283
tracing::warn!("Failed to decode image overlay {}: {}", i, e);
@@ -409,6 +418,7 @@ impl ProcessorNode for CompositorNode {
409418
Self::apply_update_params(
410419
&mut self.config,
411420
&mut image_overlays,
421+
&mut image_overlay_cfg_indices,
412422
&mut text_overlays,
413423
params,
414424
&mut stats_tracker,
@@ -490,6 +500,7 @@ impl ProcessorNode for CompositorNode {
490500
Self::apply_update_params(
491501
&mut self.config,
492502
&mut image_overlays,
503+
&mut image_overlay_cfg_indices,
493504
&mut text_overlays,
494505
params,
495506
&mut stats_tracker,
@@ -530,6 +541,7 @@ impl ProcessorNode for CompositorNode {
530541
Self::apply_update_params(
531542
&mut self.config,
532543
&mut image_overlays,
544+
&mut image_overlay_cfg_indices,
533545
&mut text_overlays,
534546
params,
535547
&mut stats_tracker,
@@ -652,6 +664,7 @@ impl CompositorNode {
652664
fn apply_update_params(
653665
config: &mut CompositorConfig,
654666
image_overlays: &mut Arc<[Arc<DecodedOverlay>]>,
667+
image_overlay_cfg_indices: &mut Vec<usize>,
655668
text_overlays: &mut Arc<[Arc<DecodedOverlay>]>,
656669
params: serde_json::Value,
657670
stats_tracker: &mut NodeStatsTracker,
@@ -673,76 +686,71 @@ impl CompositorNode {
673686
// bitmaps are reused via Arc, avoiding redundant base64
674687
// decode + bilinear prescale work.
675688
//
676-
// We match cached overlays by content identity rather
677-
// than positional index because failed decodes cause the
678-
// decoded slice to be shorter than the config vec,
679-
// making positional lookups incorrect.
689+
// The cache is keyed by (data_base64, width, height).
690+
// `image_overlay_cfg_indices` provides an exact mapping
691+
// from each decoded overlay back to its originating
692+
// config index, eliminating any heuristic guessing
693+
// about which decoded bitmap belongs to which config.
680694
let old_imgs = image_overlays.clone();
681695
let old_cfgs = &config.image_overlays;
682696

683-
// Build a content-keyed cache from the successfully
684-
// decoded old overlays. We match each decoded bitmap
685-
// back to its originating config by walking old_cfgs
686-
// and old_imgs together: for each old config we check
687-
// whether the next decoded overlay's dimensions match
688-
// the config's target rect (a successful decode
689-
// prescales to exactly those dimensions). On mismatch
690-
// we skip the config (it must have been a failed
691-
// decode) without consuming a decoded entry.
692-
//
693-
// The cache key is (data_base64, width, height) and
694-
// values are Vec to handle duplicate images at the
695-
// same size.
696-
type CacheKey<'a> = (&'a str, u32, u32);
697-
let mut cache: HashMap<CacheKey<'_>, Vec<Arc<DecodedOverlay>>> = HashMap::new();
698-
let mut dec_iter = old_imgs.iter().peekable();
699-
for old_cfg in old_cfgs.iter() {
700-
if let Some(decoded) = dec_iter.peek() {
701-
let tw = old_cfg.transform.rect.width;
702-
let th = old_cfg.transform.rect.height;
703-
// A successfully decoded overlay is prescaled
704-
// to the config's target dimensions. If the
705-
// next decoded bitmap's size matches we know
706-
// it belongs to this config; otherwise this
707-
// config's decode must have failed.
708-
if decoded.width == tw && decoded.height == th {
709-
let key: CacheKey<'_> = (&old_cfg.data_base64, tw, th);
710-
cache
711-
.entry(key)
712-
.or_default()
713-
.push(Arc::clone(dec_iter.next().expect("peeked")));
697+
let mut cache: HashMap<(&str, u32, u32), Vec<Arc<DecodedOverlay>>> =
698+
HashMap::new();
699+
700+
// Each decoded overlay has a recorded config index in
701+
// `image_overlay_cfg_indices`. Use this to look up
702+
// the originating config directly — no dimension
703+
// matching needed.
704+
for (dec_idx, decoded) in old_imgs.iter().enumerate() {
705+
if let Some(&cfg_idx) = image_overlay_cfg_indices.get(dec_idx) {
706+
if let Some(old_cfg) = old_cfgs.get(cfg_idx) {
707+
let key = (
708+
old_cfg.data_base64.as_str(),
709+
old_cfg.transform.rect.width,
710+
old_cfg.transform.rect.height,
711+
);
712+
cache.entry(key).or_default().push(Arc::clone(decoded));
714713
}
715714
}
716715
}
717716

718717
let mut new_image_overlays: Vec<Arc<DecodedOverlay>> =
719718
Vec::with_capacity(new_config.image_overlays.len());
720-
for img_cfg in &new_config.image_overlays {
721-
let key: CacheKey<'_> = (
722-
&img_cfg.data_base64,
719+
let mut new_cfg_indices: Vec<usize> =
720+
Vec::with_capacity(new_config.image_overlays.len());
721+
for (new_idx, img_cfg) in new_config.image_overlays.iter().enumerate() {
722+
let key = (
723+
img_cfg.data_base64.as_str(),
723724
img_cfg.transform.rect.width,
724725
img_cfg.transform.rect.height,
725726
);
726727
if let Some(entries) = cache.get_mut(&key) {
727728
if let Some(existing) = entries.pop() {
728729
// Content and target dimensions unchanged —
729-
// reuse the decoded bitmap, just update
730-
// mutable transform fields.
730+
// reuse the decoded bitmap. The cache key
731+
// already guarantees width/height match, so
732+
// only position and visual fields can differ.
731733
let mut ov = (*existing).clone();
732-
ov.rect = img_cfg.transform.rect.clone();
734+
ov.rect.x = img_cfg.transform.rect.x;
735+
ov.rect.y = img_cfg.transform.rect.y;
733736
ov.opacity = img_cfg.transform.opacity;
734737
ov.rotation_degrees = img_cfg.transform.rotation_degrees;
735738
ov.z_index = img_cfg.transform.z_index;
736739
new_image_overlays.push(Arc::new(ov));
740+
new_cfg_indices.push(new_idx);
737741
continue;
738742
}
739743
}
740744
match decode_image_overlay(img_cfg) {
741-
Ok(ov) => new_image_overlays.push(Arc::new(ov)),
745+
Ok(ov) => {
746+
new_image_overlays.push(Arc::new(ov));
747+
new_cfg_indices.push(new_idx);
748+
},
742749
Err(e) => tracing::warn!("Image overlay decode failed: {e}"),
743750
}
744751
}
745752
*image_overlays = Arc::from(new_image_overlays);
753+
*image_overlay_cfg_indices = new_cfg_indices;
746754

747755
// Re-rasterize text overlays.
748756
let new_text_overlays: Vec<Arc<DecodedOverlay>> = new_config

crates/nodes/src/video/compositor/overlay.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ pub fn decode_image_overlay(config: &ImageOverlayConfig) -> Result<DecodedOverla
8282
/// containing text or fine detail. Called once at config time so the
8383
/// per-frame blit is a 1:1 copy.
8484
fn prescale_rgba(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8> {
85-
// SAFETY: caller guarantees src.len() == sw * sh * 4.
85+
// Invariant: caller guarantees src.len() == sw * sh * 4.
8686
#[allow(clippy::expect_used)]
8787
// from_raw only fails if buffer length != w*h*4; caller guarantees this
8888
let src_img = image::RgbaImage::from_raw(sw, sh, src.to_vec())

ui/src/components/CompositorCanvas.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -405,18 +405,37 @@ const ImageOverlayLayer: React.FC<{
405405
const borderColor = isSelected ? 'var(--sk-primary)' : `hsla(${hue}, 70%, 65%, 0.8)`;
406406
const bgColor = isSelected ? `hsla(${hue}, 60%, 50%, 0.25)` : `hsla(${hue}, 60%, 50%, 0.12)`;
407407

408-
// Build a data-URI for the image thumbnail. The overlay stores raw
409-
// base64 — we detect the MIME type from the first bytes of the decoded
410-
// header (magic-byte prefixes in base64 encoding).
408+
// Build an object URL for the image thumbnail. Using
409+
// URL.createObjectURL avoids keeping a potentially large data-URI
410+
// string in the DOM and is more memory-efficient for multi-MB images.
411+
//
412+
// MIME detection: we inspect the base64-encoded magic bytes at the
413+
// start of the string to pick the correct MIME type. The fallback
414+
// is JPEG, which covers the common `/9j/` prefix and variants.
411415
const imgSrc = useMemo(() => {
412416
if (!overlay.dataBase64) return undefined;
413417
let mime = 'image/jpeg'; // default fallback
414418
if (overlay.dataBase64.startsWith('iVBOR')) mime = 'image/png';
415419
else if (overlay.dataBase64.startsWith('R0lGOD')) mime = 'image/gif';
416420
else if (overlay.dataBase64.startsWith('UklGR')) mime = 'image/webp';
417-
return `data:${mime};base64,${overlay.dataBase64}`;
421+
else if (overlay.dataBase64.startsWith('Qk')) mime = 'image/bmp';
422+
const binaryStr = atob(overlay.dataBase64);
423+
const bytes = new Uint8Array(binaryStr.length);
424+
for (let i = 0; i < binaryStr.length; i++) {
425+
bytes[i] = binaryStr.charCodeAt(i);
426+
}
427+
const blob = new Blob([bytes], { type: mime });
428+
return URL.createObjectURL(blob);
418429
}, [overlay.dataBase64]);
419430

431+
// Revoke the object URL when the component unmounts or the source
432+
// changes to avoid memory leaks.
433+
useEffect(() => {
434+
return () => {
435+
if (imgSrc) URL.revokeObjectURL(imgSrc);
436+
};
437+
}, [imgSrc]);
438+
420439
return (
421440
<LayerBox
422441
ref={layerRef}

0 commit comments

Comments
 (0)