Skip to content

feat: add device frame mockups for video export#294

Open
mvanhorn wants to merge 6 commits intosiddharthvaddem:mainfrom
mvanhorn:feat/device-frames
Open

feat: add device frame mockups for video export#294
mvanhorn wants to merge 6 commits intosiddharthvaddem:mainfrom
mvanhorn:feat/device-frames

Conversation

@mvanhorn
Copy link
Copy Markdown

@mvanhorn mvanhorn commented Apr 3, 2026

Summary

Adds MacBook and Browser window frame overlays that wrap recordings in device mockups. The frame renders as the final compositing layer on top of all content (video, webcam, annotations) in both MP4 and GIF exports.

Why this matters

OpenScreen positions itself as a free Screen Studio alternative. Device frame mockups are one of Screen Studio's most recognizable features. They make demos look polished without post-production.

Source Evidence
Screen Studio Device frames are a core paid feature ($29/mo)
README "OpenScreen is your free, open-source alternative to Screen Studio"
PR #229 Webcam overlay compositing (same layer pattern this builds on)

Changes

  • src/lib/deviceFrames.ts (new): Frame definitions with screen area coordinates for each device type
  • public/frames/macbook.svg (new): MacBook Pro frame SVG
  • public/frames/browser.svg (new): Browser window frame SVG
  • src/hooks/useEditorHistory.ts: Added deviceFrame to EditorState (default: "none")
  • src/lib/exporter/frameRenderer.ts: Loads device frame image during init, draws it as the topmost compositing layer in renderFrame()
  • src/lib/exporter/videoExporter.ts: Passes deviceFrame config to FrameRenderer
  • src/lib/exporter/gifExporter.ts: Same for GIF export path
  • src/components/video-editor/SettingsPanel.tsx: Frame selector UI (None/MacBook/Browser buttons)
  • src/components/video-editor/VideoEditor.tsx: Wires deviceFrame state through save/export paths
  • src/components/video-editor/projectPersistence.ts: Persists frame selection in project files

How it works

  1. User selects a device frame in the settings panel (None, MacBook, Browser)
  2. During export, FrameRenderer.loadDeviceFrame() loads the SVG as an HTMLImageElement
  3. After all content layers are composited (background, video, shadows, webcam, annotations), drawDeviceFrame() draws the frame overlay on top
  4. SVG frames scale cleanly to any export resolution
  5. "None" is the default - no frame renders unless explicitly selected

The frame overlay approach is intentionally simple: the SVG image is drawn on top of the composite canvas at full size. The video content underneath already fills the frame's screen area because the frame SVGs have transparent screen regions.

Testing

  • TypeScript compiles cleanly (npx tsc --noEmit)
  • Biome lint passes (pre-commit hook)
  • No changes to existing tests needed (feature is additive, default is "none")
  • Manual testing requires running the Electron app with a screen recording loaded

This contribution was developed with AI assistance (Claude Code).

Summary by CodeRabbit

  • New Features
    • Device frame selection in Settings (None, MacBook, Browser).
    • Live preview of device frames during playback with overlay and scaled video area.
    • Device frames included in video and GIF exports.
    • Device frame choice persisted in projects and tracked by undo/redo history.
    • Added a standalone demo page to preview and test device frame layouts.

Adds MacBook and Browser window frame overlays that wrap recordings
in device mockups. Frames are SVG-based and render as the final
compositing layer in both MP4 and GIF exports.

Changes:
- New deviceFrame field in EditorState (default: "none")
- Frame definitions in src/lib/deviceFrames.ts
- SVG frame assets in public/frames/
- Frame selector UI in SettingsPanel (None/MacBook/Browser)
- Frame rendering in FrameRenderer export pipeline
- Project persistence support for device frame selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 3, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a device-frame feature: new device definitions and lookup, SettingsPanel UI and editor-state plumbing, playback overlay/positioning, and exporter/frame-renderer support to composite video/webcam into device mockups for preview and export.

Changes

Cohort / File(s) Summary
Settings & Editor UI
src/components/video-editor/SettingsPanel.tsx, src/components/video-editor/VideoEditor.tsx, src/components/video-editor/VideoPlayback.tsx
Added deviceFrame and onDeviceFrameChange props and UI control; threaded selection through VideoEditor state and history; VideoPlayback positions/scales the video container and overlays the frame image when active.
State & Persistence
src/hooks/useEditorHistory.ts, src/components/video-editor/projectPersistence.ts
Added deviceFrame: string to EditorState and ProjectEditorState; initialized default "none" and normalized into project snapshots.
Device Definitions
src/lib/deviceFrames.ts
New module exporting DeviceFrameDefinition, DEVICE_FRAMES (macbook, browser) with imagePath and percentage-based screen rects, and getDeviceFrame(id) lookup.
Rendering / Export Pipeline
src/lib/exporter/frameRenderer.ts, src/lib/exporter/gifExporter.ts, src/lib/exporter/videoExporter.ts
FrameRenderer accepts optional deviceFrame, loads frame image (non-fatal on error), computes/clips/scales video+webcam into frame screen rect and draws overlay; Gif/Video exporters forward deviceFrame.
Demo / Static Preview
public/device-frame-demo.html
New static demo page illustrating device-frame selection, positioning, and SVG overlay preview for none / macbook / browser.

Sequence Diagram

sequenceDiagram
    participant User
    participant SettingsPanel
    participant VideoEditor
    participant EditorHistory
    participant Exporter
    participant FrameRenderer

    User->>SettingsPanel: select device frame ("macbook")
    SettingsPanel->>VideoEditor: onDeviceFrameChange("macbook")
    VideoEditor->>EditorHistory: push change { deviceFrame: "macbook" }
    EditorHistory-->>VideoEditor: state updated with deviceFrame

    Note over VideoEditor,Exporter: Export flow
    VideoEditor->>Exporter: start export (deviceFrame: "macbook")
    Exporter->>FrameRenderer: initialize(config.deviceFrame)
    FrameRenderer->>FrameRenderer: getDeviceFrame("macbook") -> load image
    FrameRenderer->>FrameRenderer: compute screen rect & scale video/webcam into rect
    FrameRenderer->>FrameRenderer: drawDeviceFrame() overlay
    FrameRenderer-->>Exporter: frames rendered
    Exporter-->>User: delivered export with device frame
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • siddharthvaddem

Poem

🐰 I hopped through pixels, stitched a frame on high,
MacBook, browser, or plain — I let the canvas sigh,
A click in settings, history keeps the tune,
Frames perched above the video, glowing like the moon,
Export hums softly — rabbit-crafted view ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add device frame mockups for video export' accurately and concisely summarizes the main change—introducing device frame overlays for exports.
Description check ✅ Passed The description covers most required template sections: it includes a clear summary, motivation with evidence, a detailed change list, explanation of how the feature works, and testing notes. However, screenshots/videos are not included despite the template requesting them, and the PR has unresolved reviewer feedback about visual rendering.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3203879e56

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

return JSON.stringify(
createProjectData(currentProjectMedia, {
wallpaper,
deviceFrame,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restore device frame when applying loaded project state

currentProjectSnapshot now serializes deviceFrame, but applyLoadedProject() never writes normalizedEditor.deviceFrame into pushState. When a user opens a project saved with a non-default frame, the editor keeps the stale in-memory frame value, so subsequent exports can use the wrong mockup and the project is immediately marked dirty because currentProjectSnapshot no longer matches lastSavedSnapshot.

Useful? React with 👍 / 👎.

const w = this.config.width;
const h = this.config.height;

ctx.drawImage(this.deviceFrameImage, 0, 0, w, h);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply frame screen bounds before drawing overlay

drawDeviceFrame() stretches the SVG over the full export canvas and never uses the screen geometry defined in DEVICE_FRAMES, so the compositor still lays out video/webcam/annotations against the full canvas instead of the frame cutout. In practice this misaligns content for the MacBook/Browser templates (content is cropped or padded inside the transparent window), especially when padding or webcam layouts are changed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (1)
src/components/video-editor/VideoEditor.tsx (1)

185-200: ⚠️ Potential issue | 🟠 Major

Missing deviceFrame when restoring project state.

The applyLoadedProject function pushes normalized editor values to state but omits deviceFrame. This means when a project is loaded, the saved device frame selection is lost and defaults back to "none".

🐛 Proposed fix
       pushState({
         wallpaper: normalizedEditor.wallpaper,
+        deviceFrame: normalizedEditor.deviceFrame,
         shadowIntensity: normalizedEditor.shadowIntensity,
         showBlur: normalizedEditor.showBlur,
         motionBlurAmount: normalizedEditor.motionBlurAmount,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/video-editor/VideoEditor.tsx` around lines 185 - 200, The
applyLoadedProject routine is restoring normalizedEditor values via pushState
but omits deviceFrame, causing loaded projects to lose saved device frame
selection; update the pushState call inside applyLoadedProject (the block that
sets wallpaper, shadowIntensity, showBlur, etc.) to also include deviceFrame:
normalizedEditor.deviceFrame so the saved deviceFrame is restored when the
project is loaded.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/video-editor/SettingsPanel.tsx`:
- Around line 733-757: The "Device Frame" block in SettingsPanel.tsx currently
uses hardcoded labels; replace the static strings ("Device Frame", "None",
"MacBook", "Browser") with i18n keys (for example settings.deviceFrame,
settings.deviceFrame.none, settings.deviceFrame.macbook,
settings.deviceFrame.browser) and call the translation function t() where those
strings are used (e.g., for the header and for each frame.label in the mapping),
ensuring the component imports/uses the same t() instance as the rest of the
panel and that corresponding keys are added to the project's i18n translation
files for each supported locale; keep the onClick handler (onDeviceFrameChange)
and the deviceFrame equality checks unchanged.

In `@src/lib/exporter/frameRenderer.ts`:
- Around line 330-352: loadDeviceFrame() currently fetches the
DeviceFrameDefinition via getDeviceFrame(frameId) but only uses
frameDef.imagePath; capture and store the screen positioning data (x, y, width,
height) from the returned DeviceFrameDefinition into a new/existing property
(e.g. this.deviceFrameScreen) so downstream layout code can position video
inside the frame; specifically, after const frameDef = getDeviceFrame(frameId)
and before loading the image, assign this.deviceFrameScreen = { x: frameDef.x,
y: frameDef.y, width: frameDef.width, height: frameDef.height } (normalizing
types if needed) and keep this.deviceFrameImage assignment as-is so later video
layout computation can reference this.deviceFrameScreen.
- Around line 354-362: The device frame's screen rectangle
(DeviceFrameDefinition.screen) is computed but never applied, so drawDeviceFrame
currently paints the full frame over the canvas and hides video; fix by
converting the screen percentages to pixel coordinates using
this.config.width/height (compute screenX, screenY, screenW, screenH from
deviceFrame.screen), then ensure video rendering is constrained to that rect —
either (preferred) draw video using ctx.drawImage(source, sx, sy, sw, sh,
screenX, screenY, screenW, screenH) or apply a clipping mask (ctx.save();
ctx.beginPath(); ctx.rect(screenX, screenY, screenW, screenH); ctx.clip(); draw
video; ctx.restore()) before drawing the bezel image with
drawImage(this.deviceFrameImage, 0,0,w,h); update drawDeviceFrame and the
video-rendering location/functions to use the computed screen rect so video
appears only in the frame opening.

---

Outside diff comments:
In `@src/components/video-editor/VideoEditor.tsx`:
- Around line 185-200: The applyLoadedProject routine is restoring
normalizedEditor values via pushState but omits deviceFrame, causing loaded
projects to lose saved device frame selection; update the pushState call inside
applyLoadedProject (the block that sets wallpaper, shadowIntensity, showBlur,
etc.) to also include deviceFrame: normalizedEditor.deviceFrame so the saved
deviceFrame is restored when the project is loaded.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 662a8f2c-9658-4a93-938e-23cb1be2d6f3

📥 Commits

Reviewing files that changed from the base of the PR and between b101820 and 3203879.

⛔ Files ignored due to path filters (3)
  • package-lock.json is excluded by !**/package-lock.json
  • public/frames/browser.svg is excluded by !**/*.svg
  • public/frames/macbook.svg is excluded by !**/*.svg
📒 Files selected for processing (8)
  • src/components/video-editor/SettingsPanel.tsx
  • src/components/video-editor/VideoEditor.tsx
  • src/components/video-editor/projectPersistence.ts
  • src/hooks/useEditorHistory.ts
  • src/lib/deviceFrames.ts
  • src/lib/exporter/frameRenderer.ts
  • src/lib/exporter/gifExporter.ts
  • src/lib/exporter/videoExporter.ts

Comment on lines +733 to +757
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[10px] font-medium text-slate-300">Device Frame</div>
</div>
<div className="flex gap-1.5">
{[
{ id: "none", label: "None" },
{ id: "macbook", label: "MacBook" },
{ id: "browser", label: "Browser" },
].map((frame) => (
<button
key={frame.id}
type="button"
onClick={() => onDeviceFrameChange?.(frame.id)}
className={`flex-1 text-[9px] font-medium px-2 py-1.5 rounded-md transition-all ${
deviceFrame === frame.id
? "bg-[#34B27B] text-white"
: "bg-white/5 text-slate-400 hover:bg-white/10 hover:text-slate-300"
}`}
>
{frame.label}
</button>
))}
</div>
</div>
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

Hardcoded UI strings should use i18n translations.

The "Device Frame", "None", "MacBook", and "Browser" labels are hardcoded, while the rest of the settings panel consistently uses t() for translations. This creates an inconsistent experience for non-English users.

💬 Suggested fix

Add translation keys to your i18n files and replace hardcoded strings:

-<div className="text-[10px] font-medium text-slate-300">Device Frame</div>
+<div className="text-[10px] font-medium text-slate-300">{t("effects.deviceFrame")}</div>

 {[
-  { id: "none", label: "None" },
-  { id: "macbook", label: "MacBook" },
-  { id: "browser", label: "Browser" },
+  { id: "none", label: t("effects.deviceFrameNone") },
+  { id: "macbook", label: t("effects.deviceFrameMacbook") },
+  { id: "browser", label: t("effects.deviceFrameBrowser") },
 ].map((frame) => (
📝 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
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[10px] font-medium text-slate-300">Device Frame</div>
</div>
<div className="flex gap-1.5">
{[
{ id: "none", label: "None" },
{ id: "macbook", label: "MacBook" },
{ id: "browser", label: "Browser" },
].map((frame) => (
<button
key={frame.id}
type="button"
onClick={() => onDeviceFrameChange?.(frame.id)}
className={`flex-1 text-[9px] font-medium px-2 py-1.5 rounded-md transition-all ${
deviceFrame === frame.id
? "bg-[#34B27B] text-white"
: "bg-white/5 text-slate-400 hover:bg-white/10 hover:text-slate-300"
}`}
>
{frame.label}
</button>
))}
</div>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1.5">
<div className="text-[10px] font-medium text-slate-300">{t("effects.deviceFrame")}</div>
</div>
<div className="flex gap-1.5">
{[
{ id: "none", label: t("effects.deviceFrameNone") },
{ id: "macbook", label: t("effects.deviceFrameMacbook") },
{ id: "browser", label: t("effects.deviceFrameBrowser") },
].map((frame) => (
<button
key={frame.id}
type="button"
onClick={() => onDeviceFrameChange?.(frame.id)}
className={`flex-1 text-[9px] font-medium px-2 py-1.5 rounded-md transition-all ${
deviceFrame === frame.id
? "bg-[`#34B27B`] text-white"
: "bg-white/5 text-slate-400 hover:bg-white/10 hover:text-slate-300"
}`}
>
{frame.label}
</button>
))}
</div>
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/video-editor/SettingsPanel.tsx` around lines 733 - 757, The
"Device Frame" block in SettingsPanel.tsx currently uses hardcoded labels;
replace the static strings ("Device Frame", "None", "MacBook", "Browser") with
i18n keys (for example settings.deviceFrame, settings.deviceFrame.none,
settings.deviceFrame.macbook, settings.deviceFrame.browser) and call the
translation function t() where those strings are used (e.g., for the header and
for each frame.label in the mapping), ensuring the component imports/uses the
same t() instance as the rest of the panel and that corresponding keys are added
to the project's i18n translation files for each supported locale; keep the
onClick handler (onDeviceFrameChange) and the deviceFrame equality checks
unchanged.

@siddharthvaddem
Copy link
Copy Markdown
Owner

Tested it out - doesn't work as expected. Ideally there should be preview rendering so the user knows how it would look. Video content is not repositioned inside the frame
image

this is how it looks all video, you seem to be doing this on top of the video. You want to composite the video inside the frame and not on top

Adding videos as proof/ demo will help a lot when reviewing

…rlay

The video content was being composited on top of the device frame at full
canvas size instead of scaled and positioned within the frame's screen
region. This fix:

- Adds getDeviceScreenRect() to compute pixel-space screen area from the
  frame definition percentages
- When a device frame is active, clips and scales video content to fit
  inside the frame's screen area before drawing the frame overlay on top
- Adds deviceFrame prop to VideoPlayback so the preview shows the frame
  overlay with the PixiJS canvas constrained to the frame's screen region

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (3)
src/components/video-editor/VideoEditor.tsx (2)

185-200: ⚠️ Potential issue | 🟠 Major

Project load still drops the saved deviceFrame.

deviceFrame is persisted in snapshots here, but applyLoadedProject() never writes normalizedEditor.deviceFrame back into history state. Because pushState() is partial, loading a project can keep the previous session's frame selection and immediately mark the project dirty.

🧩 Proposed fix
 			pushState({
 				wallpaper: normalizedEditor.wallpaper,
+				deviceFrame: normalizedEditor.deviceFrame ?? "none",
 				shadowIntensity: normalizedEditor.shadowIntensity,
 				showBlur: normalizedEditor.showBlur,
 				motionBlurAmount: normalizedEditor.motionBlurAmount,

Also applies to: 255-256

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/video-editor/VideoEditor.tsx` around lines 185 - 200, The
history push when applying a loaded project omits normalizedEditor.deviceFrame,
so applyLoadedProject should include deviceFrame in the object passed to
pushState (add deviceFrame: normalizedEditor.deviceFrame alongside wallpaper,
shadowIntensity, etc.); also update the other pushState call that mirrors this
block (the second occurrence that handles project load/restore) to include
deviceFrame as well so loading a project fully replaces the previous session's
frame selection and avoids marking the project dirty.

1068-1072: ⚠️ Potential issue | 🟠 Major

Use the outer preview size for framed exports.

After the VideoPlayback change, containerRef measures only the inner device-screen rect, but annotations are still authored against the full preview overlay. Passing that smaller size into both exporters changes FrameRenderer's annotation scale factor whenever a frame is enabled, so annotated exports won't match the preview.

🧩 One way to fix it
-				const containerElement = playbackRef?.containerRef?.current;
-				const previewWidth = containerElement?.clientWidth || 1920;
-				const previewHeight = containerElement?.clientHeight || 1080;
+				const previewElement = playbackRef?.previewRef?.current;
+				const previewWidth = previewElement?.clientWidth || 1920;
+				const previewHeight = previewElement?.clientHeight || 1080;

That needs a separate previewRef (or equivalent getPreviewSize() API) exposed from VideoPlayback for the outer preview wrapper, instead of reusing the now frame-local containerRef.

Also applies to: 1074-1105, 1209-1237

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/video-editor/VideoEditor.tsx` around lines 1068 - 1072, The
current code reads the inner device-screen size from
videoPlaybackRef.current.containerRef which was changed to be frame-local,
causing exporters to scale annotations incorrectly; update VideoPlayback to
expose the outer preview dimensions (e.g., add a previewRef or getPreviewSize()
on the VideoPlayback component) and switch usages in VideoEditor (where you read
previewWidth/previewHeight) to call that new API (e.g.,
videoPlaybackRef.current.previewRef.current.clientWidth / getPreviewSize())
instead of containerRef; ensure FrameRenderer and both exporters use this outer
preview size so annotation scaling matches the preview.
src/components/video-editor/VideoPlayback.tsx (1)

205-229: ⚠️ Potential issue | 🟠 Major

frameScreen splits the preview into two coordinate spaces.

layoutVideoContent() now computes geometry inside the inner screen rect, but updateOverlayForRegion() still overwrites stageSizeRef from the outer overlay and the webcam DOM node still renders against the outer wrapper. With a frame selected, zoom preview/drag and webcam placement drift away from the actual video, while export already offsets them into the frame. Keep those layers in the same positioned space, or apply the frame offset before using their coordinates.

Also applies to: 1155-1165, 1181-1204

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/video-editor/VideoPlayback.tsx` around lines 205 - 229,
updateOverlayForRegion is still using the outer overlay's
clientWidth/clientHeight to set stageSizeRef and positioning the focus
indicator/webcam against the outer wrapper, while layoutVideoContent() now
computes geometry inside the inner "frameScreen" rect; this causes
preview/drag/webcam to drift when a frame is selected. Fix by computing and
applying the frameScreen offset/inner rect before setting stageSizeRef and
before calling updateOverlayIndicator: derive stage size and position from the
inner screen rectangle produced by layoutVideoContent (or accept and pass a
frameOffset/innerRect parameter into
updateOverlayForRegion/updateOverlayIndicator), adjust overlayEl/indicatorEl
coordinates by that offset, and ensure webcam DOM rendering uses the same inner
rect positioning instead of the outer overlay; also update the other occurrences
(the other updateOverlayForRegion usages noted) to use the same inner-rect
logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/components/video-editor/VideoEditor.tsx`:
- Around line 185-200: The history push when applying a loaded project omits
normalizedEditor.deviceFrame, so applyLoadedProject should include deviceFrame
in the object passed to pushState (add deviceFrame: normalizedEditor.deviceFrame
alongside wallpaper, shadowIntensity, etc.); also update the other pushState
call that mirrors this block (the second occurrence that handles project
load/restore) to include deviceFrame as well so loading a project fully replaces
the previous session's frame selection and avoids marking the project dirty.
- Around line 1068-1072: The current code reads the inner device-screen size
from videoPlaybackRef.current.containerRef which was changed to be frame-local,
causing exporters to scale annotations incorrectly; update VideoPlayback to
expose the outer preview dimensions (e.g., add a previewRef or getPreviewSize()
on the VideoPlayback component) and switch usages in VideoEditor (where you read
previewWidth/previewHeight) to call that new API (e.g.,
videoPlaybackRef.current.previewRef.current.clientWidth / getPreviewSize())
instead of containerRef; ensure FrameRenderer and both exporters use this outer
preview size so annotation scaling matches the preview.

In `@src/components/video-editor/VideoPlayback.tsx`:
- Around line 205-229: updateOverlayForRegion is still using the outer overlay's
clientWidth/clientHeight to set stageSizeRef and positioning the focus
indicator/webcam against the outer wrapper, while layoutVideoContent() now
computes geometry inside the inner "frameScreen" rect; this causes
preview/drag/webcam to drift when a frame is selected. Fix by computing and
applying the frameScreen offset/inner rect before setting stageSizeRef and
before calling updateOverlayIndicator: derive stage size and position from the
inner screen rectangle produced by layoutVideoContent (or accept and pass a
frameOffset/innerRect parameter into
updateOverlayForRegion/updateOverlayIndicator), adjust overlayEl/indicatorEl
coordinates by that offset, and ensure webcam DOM rendering uses the same inner
rect positioning instead of the outer overlay; also update the other occurrences
(the other updateOverlayForRegion usages noted) to use the same inner-rect
logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 87a6c60d-6586-400d-8cce-3e30dc577ab7

📥 Commits

Reviewing files that changed from the base of the PR and between 3203879 and 695ba31.

📒 Files selected for processing (3)
  • src/components/video-editor/VideoEditor.tsx
  • src/components/video-editor/VideoPlayback.tsx
  • src/lib/exporter/frameRenderer.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
src/lib/exporter/frameRenderer.ts (1)

452-476: ⚠️ Potential issue | 🟠 Major

Annotations are not adjusted for device frame positioning.

When a device frame is active, the video content is scaled and offset to fit within the frame's screen rectangle (lines 707-714 compute offsetX, offsetY, scale). However, annotations are rendered at lines 464-471 using full canvas coordinates without accounting for this transformation.

This means annotations will appear at their original canvas positions rather than scaled/offset to align with the video content inside the device frame. Parts of annotations may also be rendered behind the device frame's bezel.

Consider either:

  1. Passing the device frame's transform (offset, scale) to renderAnnotations() when a frame is active, or
  2. Moving annotation rendering inside compositeWithShadows() within the device-frame-active branch (before ctx.restore())
🔧 Suggested approach (Option 2 - render annotations inside clip region)
 // Inside compositeWithShadows(), after drawing webcam in device frame mode (before ctx.restore()):
+
+      // Render annotations scaled to device frame screen
+      if (
+        this.config.annotationRegions &&
+        this.config.annotationRegions.length > 0
+      ) {
+        const previewWidth = this.config.previewWidth || 1920;
+        const previewHeight = this.config.previewHeight || 1080;
+        const baseScaleX = this.config.width / previewWidth;
+        const baseScaleY = this.config.height / previewHeight;
+        const scaleFactor = ((baseScaleX + baseScaleY) / 2) * scale;
+        
+        ctx.save();
+        ctx.translate(offsetX, offsetY);
+        ctx.scale(scale, scale);
+        await renderAnnotations(
+          ctx,
+          this.config.annotationRegions,
+          w,
+          h,
+          timeMs,
+          scaleFactor / scale, // Adjust for the scale we already applied
+        );
+        ctx.restore();
+      }
+
       ctx.restore();
       return;
     }

Note: This would require passing timeMs into compositeWithShadows() or computing it there.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/exporter/frameRenderer.ts` around lines 452 - 476, Annotations are
being drawn with renderAnnotations(this.compositeCtx, ...) using full-canvas
coordinates and thus ignore the device-frame transform computed when a frame is
active (offsetX, offsetY, scale inside compositeWithShadows / device-frame
branch), causing misaligned or occluded annotations; fix by applying the
device-frame transform to annotation rendering: either pass the computed
transform (offsetX, offsetY, scale) and timeMs into renderAnnotations so it can
map annotation coordinates into the framed screen rectangle, or move the
renderAnnotations call into compositeWithShadows inside the device-frame-active
branch (before the ctx.restore()) so annotations are drawn inside the
clipped/translated context; update calls and signatures accordingly
(renderAnnotations, compositeWithShadows, drawDeviceFrame, compositeCtx,
config.annotationRegions) and ensure timeMs is available where needed.
🧹 Nitpick comments (1)
src/lib/exporter/frameRenderer.ts (1)

729-742: Extract duplicated shadow rendering logic.

The shadow parameter calculations (lines 729-740) are identical to lines 797-806 in the non-device-frame path. Consider extracting a helper method to reduce duplication:

♻️ Suggested refactor
private renderShadowToCanvas(sourceCanvas: HTMLCanvasElement): void {
  if (!this.shadowCanvas || !this.shadowCtx) return;
  
  const w = this.shadowCanvas.width;
  const h = this.shadowCanvas.height;
  const shadowCtx = this.shadowCtx;
  
  shadowCtx.clearRect(0, 0, w, h);
  shadowCtx.save();
  
  const intensity = this.config.shadowIntensity;
  const baseBlur1 = 48 * intensity;
  const baseBlur2 = 16 * intensity;
  const baseBlur3 = 8 * intensity;
  const baseAlpha1 = 0.7 * intensity;
  const baseAlpha2 = 0.5 * intensity;
  const baseAlpha3 = 0.3 * intensity;
  const baseOffset = 12 * intensity;
  
  shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset / 3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset / 6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`;
  shadowCtx.drawImage(sourceCanvas, 0, 0, w, h);
  shadowCtx.restore();
}

Then call this.renderShadowToCanvas(videoCanvas) in both paths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/exporter/frameRenderer.ts` around lines 729 - 742, Duplicate shadow
rendering logic should be extracted into a helper: add a private method (e.g.,
renderShadowToCanvas(sourceCanvas: HTMLCanvasElement)) that checks
this.shadowCanvas/this.shadowCtx, reads width/height, clears, saves, computes
intensity and baseBlur/baseAlpha/baseOffset from this.config.shadowIntensity,
sets shadowCtx.filter and draws the sourceCanvas, then restores; replace the
duplicated blocks in the device-frame and non-device-frame paths with a call to
this.renderShadowToCanvas(videoCanvas) (or appropriate canvas) to remove the
repeated code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/lib/exporter/frameRenderer.ts`:
- Around line 452-476: Annotations are being drawn with
renderAnnotations(this.compositeCtx, ...) using full-canvas coordinates and thus
ignore the device-frame transform computed when a frame is active (offsetX,
offsetY, scale inside compositeWithShadows / device-frame branch), causing
misaligned or occluded annotations; fix by applying the device-frame transform
to annotation rendering: either pass the computed transform (offsetX, offsetY,
scale) and timeMs into renderAnnotations so it can map annotation coordinates
into the framed screen rectangle, or move the renderAnnotations call into
compositeWithShadows inside the device-frame-active branch (before the
ctx.restore()) so annotations are drawn inside the clipped/translated context;
update calls and signatures accordingly (renderAnnotations,
compositeWithShadows, drawDeviceFrame, compositeCtx, config.annotationRegions)
and ensure timeMs is available where needed.

---

Nitpick comments:
In `@src/lib/exporter/frameRenderer.ts`:
- Around line 729-742: Duplicate shadow rendering logic should be extracted into
a helper: add a private method (e.g., renderShadowToCanvas(sourceCanvas:
HTMLCanvasElement)) that checks this.shadowCanvas/this.shadowCtx, reads
width/height, clears, saves, computes intensity and
baseBlur/baseAlpha/baseOffset from this.config.shadowIntensity, sets
shadowCtx.filter and draws the sourceCanvas, then restores; replace the
duplicated blocks in the device-frame and non-device-frame paths with a call to
this.renderShadowToCanvas(videoCanvas) (or appropriate canvas) to remove the
repeated code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4ef2d275-b75e-422c-85aa-0b83270dbfaf

📥 Commits

Reviewing files that changed from the base of the PR and between 695ba31 and 68acd7d.

📒 Files selected for processing (3)
  • public/device-frame-demo.html
  • src/lib/deviceFrames.ts
  • src/lib/exporter/frameRenderer.ts
✅ Files skipped from review due to trivial changes (2)
  • public/device-frame-demo.html
  • src/lib/deviceFrames.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/video-editor/VideoPlayback.tsx`:
- Around line 1155-1164: Wrap the PIXI container (containerRef), webcam video
element (webcamVideo/webcamVideoPath) and overlay div (overlayRef) inside a
single parent div that gets the frameScreen transform (use transform and
transformOrigin "top left") so they share the same scaled/translated
coordinates; keep containerRef as the inner element for the rendered
content/shadow, render webcamVideo and overlayRef as siblings inside that
transformed parent, then render the frame image (activeFrameDef) outside that
transformed group with a higher zIndex (e.g., zIndex 40) so the frame stays on
top; if selection chrome must remain editable above the frame, move only that
chrome into a separate overlay outside the frame image rather than keeping
webcam/overlay untransformed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7c15b0b3-a1fd-44d3-a4dd-c11487494736

📥 Commits

Reviewing files that changed from the base of the PR and between af701ad and 72e55b3.

📒 Files selected for processing (1)
  • src/components/video-editor/VideoPlayback.tsx

Comment on lines 1155 to +1164
ref={containerRef}
className="absolute inset-0"
className="absolute"
style={{
inset: 0,
...(frameScreen
? {
transform: `translate(${frameScreen.x}%, ${frameScreen.y}%) scale(${frameScreen.width / 100}, ${frameScreen.height / 100})`,
transformOrigin: "top left",
}
: {}),
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 | 🟠 Major

Group PIXI, webcam, and overlay under the same frame transform.

This only transforms containerRef, but webcamVideo and overlayRef stay in full-canvas coordinates and still paint above the frame (zIndex 20/30 vs 15). So the editor preview can show PiP, annotations, and focus UI outside the mockup even though export composites into the device screen rect and draws the frame last.

Suggested structure
<div className="absolute inset-0" style={screenContentStyle}>
	<div ref={containerRef} className="absolute inset-0" style={{ filter: /* shadow */ }} />
	{webcamVideoPath && <video ... />}
	{pixiReady && videoReady && <div ref={overlayRef} className="absolute inset-0">...</div>}
</div>

{activeFrameDef && <img ... style={{ zIndex: 40 }} />}

If selection chrome needs to stay editable above the mockup, keep only that chrome in a separate overlay instead of the rendered content.

Also applies to: 1171-1179

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/video-editor/VideoPlayback.tsx` around lines 1155 - 1164, Wrap
the PIXI container (containerRef), webcam video element
(webcamVideo/webcamVideoPath) and overlay div (overlayRef) inside a single
parent div that gets the frameScreen transform (use transform and
transformOrigin "top left") so they share the same scaled/translated
coordinates; keep containerRef as the inner element for the rendered
content/shadow, render webcamVideo and overlayRef as siblings inside that
transformed parent, then render the frame image (activeFrameDef) outside that
transformed group with a higher zIndex (e.g., zIndex 40) so the frame stays on
top; if selection chrome must remain editable above the frame, move only that
chrome into a separate overlay outside the frame image rather than keeping
webcam/overlay untransformed.

@mvanhorn
Copy link
Copy Markdown
Author

mvanhorn commented Apr 5, 2026

Fixed the compositing. Video content now renders inside the frame instead of on top.

Changes:

  • SVG frames use masks for transparent screen cutouts so video shows through
  • Preview uses CSS transforms to position content within the frame area
  • Frame SVGs load correctly in Electron builds via Vite asset imports

Demo (None / MacBook / Browser):

device-frames-demo

Export path still needs verification - the frameRenderer compositing was also updated to position video inside the frame's screen coordinates. Happy to add more thorough demo videos once the preview is confirmed working on your end.

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.

2 participants