Skip to content

feat: Force-save for chaotic screen changes#36

Merged
VedankPurohit merged 3 commits into
mainfrom
feat/chaotic-capture-force-save
Feb 15, 2026
Merged

feat: Force-save for chaotic screen changes#36
VedankPurohit merged 3 commits into
mainfrom
feat/chaotic-capture-force-save

Conversation

@VedankPurohit

@VedankPurohit VedankPurohit commented Feb 15, 2026

Copy link
Copy Markdown
Owner

Summary

  • When the screen changes too rapidly (e.g. video playback, animations), the stability check repeatedly fails and no screenshots get saved. This adds a configurable time threshold (max_time_without_save, default 40s) that forces a save when stability checks keep failing but enough time has passed since the last save.
  • Static screens are unaffected — the force-save logic only runs when a change is detected but the screen won't stabilize.
  • Exposed as "Chaotic Save Timeout" slider in Settings → Recording (0–120s, 0 = off).

Changes

  • core/config.py — Added max_time_without_save to CaptureSettings with save/load
  • core/capture.py — Force-save logic + _last_save_time tracking in capture loop and capture_now()
  • api/schemas.py — Added to CaptureConfig and ConfigUpdateRequest (validated 0–120)
  • api/routes/status.py — GET/PUT support for the new setting
  • web/src/types/index.ts, web/src/lib/api.ts, web/src/app/settings/page.tsx — Frontend type, API client, and UI slider

Test plan

  • Python imports and config loads correctly
  • GET /api/v1/config returns max_time_without_save
  • PUT updates and persists the value
  • Pydantic rejects negative values
  • All pre-commit hooks pass (ruff, ruff-format, mypy)
  • CodeRabbit review — clean (1 mismatch found and fixed)
  • Manual UI test: slider visible in Settings → Recording

Summary by CodeRabbit

  • New Features
    • Added "Chaotic Save Timeout" setting to Recording options in settings page, allowing users to configure automatic screenshot saving (0–120 seconds). When enabled, screenshots are force-saved if the screen remains unstable beyond the timeout threshold. Defaults to 40 seconds; set to 0 to disable.

@coderabbitai

coderabbitai Bot commented Feb 15, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This change introduces a force-save timeout mechanism for screenshot captures. The system tracks elapsed time since the last save and forces a screenshot if the interval exceeds a configured threshold (default 40 seconds). Implementation spans backend configuration, capture service logic, API schema updates, and frontend settings UI.

Changes

Cohort / File(s) Summary
Configuration Schema Updates
api/schemas.py, core/config.py, web/src/types/index.ts
Added max_time_without_save field to configuration structures with default value of 40.0 seconds, range validation (0-120), and persistence in saved config.
API Layer
api/routes/status.py, web/src/lib/api.ts
Updated API endpoints and client to accept and return max_time_without_save and capture_max_time_without_save in config requests and responses.
Capture Service Logic
core/capture.py
Added internal _last_save_time timer to track time since last screenshot. Implements force-save logic when maximum idle time is exceeded, resetting the timer after each save or manual capture.
Frontend Settings UI
web/src/app/settings/page.tsx
Added "Chaotic Save Timeout" slider control under Recording section with range 0-120 seconds (step 5), displaying "Off" when value is 0.

Sequence Diagram

sequenceDiagram
    participant User
    participant Frontend as Frontend Settings
    participant API as Backend API
    participant Config as Config Service
    participant Capture as Capture Service

    User->>Frontend: Adjust "Chaotic Save Timeout" slider
    Frontend->>API: updateConfig({capture_max_time_without_save: value})
    API->>Config: Update CaptureSettings.max_time_without_save
    Config->>Config: Persist to file, update change log
    API-->>Frontend: Configuration updated
    
    Note over Capture: During normal screenshot capture loop
    Capture->>Capture: Check elapsed time since _last_save_time
    alt Time exceeds max_time_without_save (and value > 0)
        Capture->>Capture: Force-save screenshot (even if unstable)
        Capture->>Capture: Reset _last_save_time
    else Screen becomes stable
        Capture->>Capture: Save screenshot normally
        Capture->>Capture: Reset _last_save_time
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A timeout to save when chaos reigns,
No more waiting for stable screens in vain,
From backend to frontend, the config flows true,
Force-saving with flair—what a wonderful brew! 📸✨

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Force-save for chaotic screen changes' accurately summarizes the main feature: a force-save mechanism triggered by chaotic (rapidly changing) screens.
Docstring Coverage ✅ Passed Docstring coverage is 84.00% which is sufficient. The required threshold is 80.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/chaotic-capture-force-save

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
core/config.py (1)

296-298: No server-side validation on raw config load.

The API layer validates 0–120 via Pydantic, but a hand-edited JSON config with a negative value would be loaded unchecked. In practice, a negative value would disable force-save (the capture loop checks > 0), so impact is negligible. Consider adding a clamp or comment if you want defense-in-depth.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
api/tests/test_api.py (1)

99-133: ⚠️ Potential issue | 🟠 Major

Add missing mock for max_time_without_save in test_get_config.

The GET /config endpoint now reads config.capture.max_time_without_save (line 103 of api/routes/status.py), but the test mock doesn't set this required float field. Pydantic validation will fail when it tries to coerce the MagicMock object to a float, causing the test to break.

Fix: add the missing mock attribute
         mock_config.capture.quality = 95
+        mock_config.capture.max_time_without_save = 40.0
         mock_config.compression.enabled = False
core/compression.py (1)

133-169: ⚠️ Potential issue | 🟠 Major

Force recompress overwrites original_size_bytes with the already-compressed file size.

When force=True and the image is already compressed, Line 152 captures the current (compressed) file size as original_size, and Line 167 writes it to the DB via db.mark_compressed(screenshot["id"], original_size). This permanently loses the true original size, making storage savings stats inaccurate after force recompression.

Consider preserving the existing original_size_bytes if the screenshot was already compressed:

🛡️ Proposed fix
     # Get original size
-    original_size = image_path.stat().st_size
+    current_size = image_path.stat().st_size
+
+    # Preserve the true original size if already compressed
+    if force and screenshot.get("is_compressed") and screenshot.get("original_size_bytes"):
+        original_size = screenshot["original_size_bytes"]
+    else:
+        original_size = current_size

     # Open and re-save at lower quality
     with Image.open(image_path) as img:
@@ ..
     # Get new size
     new_size = image_path.stat().st_size

     # Update database
     db.mark_compressed(screenshot["id"], original_size)

-    return original_size - new_size
+    return current_size - new_size
🤖 Fix all issues with AI agents
In `@web/src/app/settings/page.tsx`:
- Around line 149-160: The handler handleForceRecompressAgeChange does not reset
the user's confirmation when the selected age changes, so call the state setter
that clears the confirmation (setForceRecompressConfirmed(false)) inside
handleForceRecompressAgeChange (e.g., immediately after
setForceRecompressAge(days)) so the confirmation checkbox is unchecked whenever
the dropdown selection changes; keep existing preview/loading logic intact so
previewForceRecompress still runs and updates setForceRecompressPreview.

In `@web/src/components/CompressionSuggestion.tsx`:
- Around line 9-14: The formatBytes function can index past the units array for
values >= 1 TB; update formatBytes so it never reads units out of bounds by
either adding the 'TB' unit to the units array or clamping the computed i to
units.length - 1 (e.g., compute i = Math.min(i, units.length - 1)), and ensure
the toFixed logic still behaves correctly when clamped; adjust references to
units and i in formatBytes accordingly.
🧹 Nitpick comments (3)
core/tests/test_compression.py (1)

286-292: Pagination offset assertions are effectively no-ops.

Line 290: ... == 0 or calls[0] == calls[0] — the right side is always True, so this assertion always passes regardless of the actual offset value.

Line 292: The fallback or calls[1].kwargs.get("offset", 0) > 0 weakens the check. Consider asserting the kwargs directly since start_force_recompress calls get_force_recompressible_screenshots(older_than_days, limit=batch_size, offset=offset):

♻️ Suggested fix for meaningful assertions
-        # First call: offset=0
-        assert calls[0].kwargs.get("offset", calls[0][1][2] if len(calls[0][1]) > 2 else 0) == 0 or calls[0] == calls[0]
-        # Check that offset increases
-        assert calls[1][1] == (30,) or calls[1].kwargs.get("offset", 0) > 0
+        # First call: offset=0
+        assert calls[0].kwargs.get("offset") == 0
+        # Second call: offset = batch_size (50)
+        assert calls[1].kwargs.get("offset") == 50
+        # Third call: offset = 2 * batch_size (100)
+        assert calls[2].kwargs.get("offset") == 100
core/config.py (1)

56-56: Default compression quality reduced from 85 to 75.

This is a silent behavioral change for new users/installations. Existing users retain their persisted value via config.load(). Ensure this is intentional and documented (e.g., in release notes), since it affects the visual quality of auto-compressed screenshots.

web/src/app/settings/page.tsx (1)

448-525: Modal accessibility: no keyboard dismiss support.

The modal overlay doesn't respond to Escape key presses. Consider adding an onKeyDown handler to improve keyboard accessibility.

♿ Proposed improvement

Add an effect to listen for Escape when the modal is open:

useEffect(() => {
  if (!showForceRecompress) return;
  const handleKey = (e: KeyboardEvent) => {
    if (e.key === 'Escape') {
      setShowForceRecompress(false);
      setForceRecompressPreview(null);
      setForceRecompressConfirmed(false);
    }
  };
  document.addEventListener('keydown', handleKey);
  return () => document.removeEventListener('keydown', handleKey);
}, [showForceRecompress]);

Comment on lines +149 to +160
const handleForceRecompressAgeChange = async (days: number) => {
setForceRecompressAge(days);
setForceRecompressLoading(true);
try {
const preview = await previewForceRecompress(days);
setForceRecompressPreview(preview);
} catch (err) {
console.error('Failed to preview:', err);
} finally {
setForceRecompressLoading(false);
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Reset the confirmation checkbox when the age selection changes.

When the user changes the age in the dropdown, forceRecompressConfirmed retains its previous value. This means a user who confirmed for "3 months" can unknowingly proceed for "1 year" without re-confirming, since the preview data updates but the checkbox stays checked.

🛡️ Proposed fix
   const handleForceRecompressAgeChange = async (days: number) => {
     setForceRecompressAge(days);
+    setForceRecompressConfirmed(false);
     setForceRecompressLoading(true);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleForceRecompressAgeChange = async (days: number) => {
setForceRecompressAge(days);
setForceRecompressLoading(true);
try {
const preview = await previewForceRecompress(days);
setForceRecompressPreview(preview);
} catch (err) {
console.error('Failed to preview:', err);
} finally {
setForceRecompressLoading(false);
}
};
const handleForceRecompressAgeChange = async (days: number) => {
setForceRecompressAge(days);
setForceRecompressConfirmed(false);
setForceRecompressLoading(true);
try {
const preview = await previewForceRecompress(days);
setForceRecompressPreview(preview);
} catch (err) {
console.error('Failed to preview:', err);
} finally {
setForceRecompressLoading(false);
}
};
🤖 Prompt for AI Agents
In `@web/src/app/settings/page.tsx` around lines 149 - 160, The handler
handleForceRecompressAgeChange does not reset the user's confirmation when the
selected age changes, so call the state setter that clears the confirmation
(setForceRecompressConfirmed(false)) inside handleForceRecompressAgeChange
(e.g., immediately after setForceRecompressAge(days)) so the confirmation
checkbox is unchecked whenever the dropdown selection changes; keep existing
preview/loading logic intact so previewForceRecompress still runs and updates
setForceRecompressPreview.

Comment on lines +9 to +14
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i > 1 ? 1 : 0)} ${units[i]}`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

formatBytes can produce undefined unit for values ≥ 1 TB.

If bytes ≥ 1099511627776 (1 TB), i becomes 4, and units[4] is undefined. While unlikely for estimated savings here, this is a reusable utility pattern that should be robust.

🛡️ Proposed fix
 function formatBytes(bytes: number): string {
   if (bytes === 0) return '0 B';
-  const units = ['B', 'KB', 'MB', 'GB'];
-  const i = Math.floor(Math.log(bytes) / Math.log(1024));
+  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+  const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
   return `${(bytes / Math.pow(1024, i)).toFixed(i > 1 ? 1 : 0)} ${units[i]}`;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i > 1 ? 1 : 0)} ${units[i]}`;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, i)).toFixed(i > 1 ? 1 : 0)} ${units[i]}`;
}
🤖 Prompt for AI Agents
In `@web/src/components/CompressionSuggestion.tsx` around lines 9 - 14, The
formatBytes function can index past the units array for values >= 1 TB; update
formatBytes so it never reads units out of bounds by either adding the 'TB' unit
to the units array or clamping the computed i to units.length - 1 (e.g., compute
i = Math.min(i, units.length - 1)), and ensure the toFixed logic still behaves
correctly when clamped; adjust references to units and i in formatBytes
accordingly.

When the screen changes too rapidly (e.g. video playback), the stability
check repeatedly fails and no screenshots get saved. This adds a
configurable time threshold (max_time_without_save, default 40s) that
forces a save when stability checks keep failing but enough time has
passed since the last save. Static screens are unaffected since the
force-save logic only runs when a change is detected.
Add the chaotic save timeout setting to the API schema, route handler,
frontend types, and settings page with a slider (0-120s, 0 = off).
@VedankPurohit
VedankPurohit force-pushed the feat/chaotic-capture-force-save branch from a580cec to 3cee584 Compare February 15, 2026 15:53
@VedankPurohit
VedankPurohit merged commit 7041c27 into main Feb 15, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant