Build a Browser Video Editor
Build a local browser video editor with Canvas effects, trimming, and export.
Introduction
30 Second Summary
A short clip can capture the right moment yet still feel awkward in a vertical feed. Deliberate framing helps that moment fill the screen without distortion.
In this project, you will build FrameLab, a local browser video editor that turns your clip into a processed vertical video. You will expose each stage through a live Canvas pipeline before exporting the selected segment as a downloadable file.
What You'll Build
You'll press Play on your own clip to watch a phone-shaped preview preserve its proportions as the inspector reveals each processed frame in real time.
By the end of this project, you'll have:
- Local clip playback inside a 9:16 preview that keeps the source proportions intact while showing a live media timestamp.
- Pixel-level effect controls that let you adjust brightness or saturation while an RGBA readout exposes the transformed centre pixel.
- A downloadable edited segment with your selected timing, crop, visual effects, and caption rendered entirely in Microsoft Edge.
- Secret Mission: Profile the per-frame pixel loop in Microsoft Edge DevTools to calculate how much of the frame budget it consumes.
Are there any prerequisites?
The project assumes advanced HTML, CSS, and JavaScript skills. You also need Visual Studio Code, Microsoft Edge, and one short local video clip.
Before We Start
A clear goal gives every later decision a target. This is your moment to choose the clip FrameLab will transform and the browser-media concept you want the finished demo to prove.
Set Up FrameLab
FrameLab exposes every Canvas 2D operation that turns a local clip into a vertical preview. A minimal shell keeps those operations visible when you add them.
A three-file workspace in Visual Studio Code holds the page structure, styling, plus browser logic. Microsoft Edge runs the project directly from those local files.
In this step, get ready to:
- Create the three-file FrameLab workspace.
- Build the two-panel editor shell plus phone-shaped preview.
- Connect the initial JavaScript state before checking the page in Microsoft Edge.
Create the FrameLab workspace
A VS Code folder keeps all three files in one workspace. The Explorer gives you a visible check that the shell exists before you add any code.
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Visual Studio Code into system search.
- Press Enter to open Visual Studio Code.
- Click File in the top menu.
- Select Open Folder.
- Choose your Desktop in the folder dialog.
- Create a folder named framelab.
- Select the framelab folder to open it.
Why Use a Plain Folder?
FrameLab has no package manager or server. The browser reads the three files directly, which keeps the media pipeline free from package tooling.
- Select New File in the Explorer toolbar.
- Enter index.html as the file name.
- Select New File in the Explorer toolbar again.
- Enter styles.css as the file name.
- Select New File in the Explorer toolbar again.
- Enter app.js as the file name.
Your Explorer now lists index.html, styles.css, plus app.js inside framelab.
Missing One of the Files?
Confirm that framelab appears at the top of the Explorer. Create any missing file from that folder so it does not land beside the workspace.
Check every extension carefully. Help me fix my FrameLab file structure.
Build the editor shell
The HTML defines every control that later steps connect to the media pipeline. You will build the structure in small sections so each browser refresh reveals another part of the editor.
- Select index.html in the Explorer.
- Create the document frame plus site header by adding this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FrameLab Video Editor</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="site-header">
<div>
<p class="eyebrow">Browser media laboratory</p>
<h1>FrameLab</h1>
</div>
<p>Inspect every frame from local file to exported video.</p>
</header>
<main class="workspace">
</main>
<video id="sourceVideo" class="source-video" preload="metadata" playsinline></video>
<script src="app.js"></script>
</body>
</html>
What Does This Structure Do?
- The stylesheet link connects styles.css to the page.
- The empty workspace marks where both editor panels belong.
- The hidden sourceVideo element gives later JavaScript a media source.
- The final script tag loads app.js after the page structure.
- Save index.html.
- Right-click index.html in the Explorer.
- Select Reveal in File Explorer on Windows or Reveal in Finder on macOS.
- Use your operating system's app chooser to open index.html in Microsoft Edge.
You will see the FrameLab heading plus its short description on an unstyled page.
- Return to index.html in Visual Studio Code.
- Insert the preview panel inside main.workspace by adding this code:
<section class="preview-panel" aria-labelledby="preview-heading">
<div class="section-heading">
<div>
<p class="eyebrow">Output canvas</p>
<h2 id="preview-heading">9:16 Preview</h2>
</div>
<span class="badge">360 × 640</span>
</div>
<div class="phone-frame">
<canvas id="previewCanvas" width="360" height="640"></canvas>
<div id="emptyState" class="empty-state">
<strong>No clip loaded</strong>
<span>Choose a short local video to begin.</span>
</div>
</div>
</section>
What Does the Preview Panel Contain?
- The previewCanvas uses a fixed width of 360 pixels.
- Its fixed height of 640 pixels creates the vertical output surface.
- The empty state covers that canvas until a clip is ready.
- Save index.html.
- Refresh Microsoft Edge.
You will see the 9:16 Preview heading plus the empty-state message.
- Return to the preview panel in index.html.
- Insert the transport plus playhead controls below div.phone-frame by adding this code:
<div class="transport">
<button id="playButton" class="secondary requires-video" type="button" disabled>Play</button>
<span id="timeReadout">0:00.00 / 0:00.00</span>
</div>
<label class="control-row">
<span>Playhead</span>
<input id="playhead" class="requires-video" type="range" min="0" max="1000" value="0" disabled>
</label>
How Is the Timeline Represented?
The playhead uses normalized values from 0 to 1000. A later step converts that position into seconds from the loaded video.
- Save index.html.
- Refresh Microsoft Edge.
You will see a disabled Play button plus a disabled Playhead slider below the preview.
- Return to the preview panel in index.html.
- Insert the trim controls below the playhead label by adding this code:
<div class="trim-grid">
<label class="control-row">
<span>Start</span>
<input id="trimStart" class="requires-video" type="range" min="0" max="1000" value="0" disabled>
</label>
<label class="control-row">
<span>End</span>
<input id="trimEnd" class="requires-video" type="range" min="0" max="1000" value="1000" disabled>
</label>
</div>
<p id="trimReadout" class="muted">Selected range: 0:00.00 to 0:00.00</p>
What Do the Trim Controls Store?
The Start slider begins at the normalized value 0. The End slider begins at 1000 so the initial range can cover a complete clip.
- Save index.html.
- Refresh Microsoft Edge.
You will see separate Start plus End sliders with a selected-range readout.
- Return to index.html.
- Insert the controls panel after section.preview-panel by adding this code:
<section class="controls-panel" aria-labelledby="controls-heading">
<div class="section-heading">
<div>
<p class="eyebrow">Editor controls</p>
<h2 id="controls-heading">Process the Clip</h2>
</div>
</div>
<label class="file-picker">
<span>Choose local video</span>
<input id="videoFile" type="file" accept="video/*">
</label>
</section>
Why Does the File Picker Stay Enabled?
The file picker starts the editing workflow. It remains available while controls that require decoded video begin disabled.
- Save index.html.
- Refresh Microsoft Edge.
You will see a second section with the Process the Clip heading plus a local-video picker.
- Return to the controls panel in index.html.
- Insert the effects fieldset below the file picker by adding this code:
<fieldset>
<legend>Pixel effects</legend>
<label class="control-row">
<span>Brightness <output id="brightnessValue">0</output></span>
<input id="brightness" class="requires-video" type="range" min="-80" max="80" value="0" disabled>
</label>
<label class="control-row">
<span>Saturation <output id="saturationValue">100%</output></span>
<input id="saturation" class="requires-video" type="range" min="0" max="200" value="100" disabled>
</label>
<button id="compareButton" class="secondary requires-video" type="button" disabled>Hold to Compare Original</button>
</fieldset>
What Will These Controls Change?
Brightness begins with a zero offset. Saturation begins at 100% so the future pixel transform initially preserves the source colors.
- Save index.html.
- Refresh Microsoft Edge.
You will see disabled Brightness plus Saturation sliders with a disabled comparison button.
- Return to the controls panel in index.html.
- Insert the caption plus export areas below the effects fieldset by adding this code:
<fieldset>
<legend>Caption layer</legend>
<label class="control-row">
<span>Overlay text</span>
<input id="overlayText" class="requires-video" type="text" maxlength="48" value="Built frame by frame" disabled>
</label>
</fieldset>
<div class="export-box">
<button id="exportButton" class="primary requires-video" type="button" disabled>Export Trimmed Clip</button>
<a id="downloadLink" class="download-link" href="#" hidden>Download processed clip</a>
<p id="statusMessage" aria-live="polite">Choose a local video to begin.</p>
<p class="muted">Export is video-only and runs in real time.</p>
</div>
What Does the Export Area Show?
- The caption input holds the text that later becomes part of each processed frame.
- The download link begins hidden because no recording exists.
- The live status begins with Choose a local video to begin..
- Save index.html.
- Refresh Microsoft Edge.
You will see the caption field plus a disabled export button. You will also see the initial status message.
- Return to the controls panel in index.html.
- Insert the inspector below div.export-box by adding this code:
<section class="inspector" aria-labelledby="inspector-heading">
<h3 id="inspector-heading">Media Inspector</h3>
<dl>
<div><dt>Source</dt><dd id="sourceDimensions">Not loaded</dd></div>
<div><dt>Frame callback</dt><dd id="callbackMode">Not started</dd></div>
<div><dt>Media time</dt><dd id="mediaTimeValue">0.000 s</dd></div>
<div><dt>Presented frame</dt><dd id="frameValue">0</dd></div>
<div><dt>Center pixel</dt><dd id="pixelValue">rgba(0, 0, 0, 0)</dd></div>
<div><dt>Processing</dt><dd id="processingValue">0.00 ms</dd></div>
<div><dt>Export</dt><dd id="outputValue">Not exported</dd></div>
</dl>
</section>
What Will the Inspector Reveal?
The inspector gives each media measurement a dedicated output. Its initial values confirm that no source or rendered frame exists yet.
- Save index.html.
- Refresh Microsoft Edge.
You will see the Media Inspector with seven initial readouts.
Is a Control Missing?
Check that each new fragment sits inside the correct section element. A fragment pasted after main.workspace can appear outside the two-panel layout.
Match every closing tag with its opening tag. Help me diagnose my FrameLab HTML structure.
The complete structure is now present. Styling turns that long browser document into a phone preview beside a compact control panel.
- Select styles.css in the Explorer.
- Add the page theme plus base element rules with this code:
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background: #090b12;
color: #f7f8ff;
--panel: #121622;
--panel-light: #1a2030;
--line: #30384d;
--muted: #9aa6bd;
--accent: #8b5cf6;
--accent-light: #b9a3ff;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at top left, #24204a 0, transparent 32rem),
#090b12;
}
button,
input {
font: inherit;
}
What Does the Base Theme Set?
The root variables centralize the panel, border, text, plus accent colors. The body adds the dark canvas behind the editor.
- Save styles.css.
- Refresh Microsoft Edge.
You will see a dark page with a purple glow in the top-left corner.
- Add the shared button styles below the existing CSS with this code:
button,
.file-picker,
.download-link {
border-radius: 0.75rem;
}
button {
border: 1px solid transparent;
padding: 0.75rem 1rem;
cursor: pointer;
}
button:disabled,
input:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.primary {
background: var(--accent);
color: white;
font-weight: 700;
}
.secondary {
border-color: var(--line);
background: var(--panel-light);
color: #f7f8ff;
}
How Do Disabled Controls Look?
Reduced opacity separates unavailable controls from the file picker. Primary plus secondary classes give later actions a visual hierarchy.
- Save styles.css.
- Refresh Microsoft Edge.
You will see rounded controls. Disabled inputs plus buttons will look faded.
- Add the site-header layout below the button styles with this code:
.site-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 2rem;
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 1rem;
}
.site-header h1,
.section-heading h2,
.inspector h3,
.site-header p {
margin: 0;
}
.site-header > p {
color: var(--muted);
text-align: right;
}
What Does the Header Layout Change?
The header shares the same maximum width as the workspace. Its title plus description sit at opposite ends on wide screens.
- Save styles.css.
- Refresh Microsoft Edge.
You will see the FrameLab title on the left with its description aligned on the right.
- Add the eyebrow plus workspace rules below the header styles with this code:
.eyebrow {
margin: 0 0 0.35rem;
color: var(--accent-light);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.workspace {
display: grid;
grid-template-columns: minmax(340px, 0.9fr) minmax(420px, 1.1fr);
gap: 1rem;
width: min(1180px, calc(100% - 2rem));
margin: 0 auto 2rem;
}
.preview-panel,
.controls-panel {
border: 1px solid var(--line);
border-radius: 1.25rem;
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.25);
}
How Does the Two-Panel Layout Work?
The workspace grid gives the preview a narrower column. Both panels share the same border, background, plus shadow.
- Save styles.css.
- Refresh Microsoft Edge.
You will see the preview panel beside the controls panel on a wide browser window.
- Add the panel-spacing plus heading rules below the workspace styles with this code:
.preview-panel {
padding: 1.25rem;
}
.controls-panel {
padding: 1.25rem;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.badge {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.4rem 0.65rem;
color: var(--muted);
font-size: 0.8rem;
}
What Does the Badge Communicate?
The compact badge keeps the canvas dimensions visible beside the preview heading. Matching panel padding keeps both columns aligned.
- Save styles.css.
- Refresh Microsoft Edge.
You will see the 360 × 640 badge aligned with the preview heading.
- Add the phone frame plus canvas rules below the badge styles with this code:
.phone-frame {
position: relative;
width: min(100%, 360px);
aspect-ratio: 9 / 16;
margin: 0 auto 1rem;
overflow: hidden;
border: 8px solid #02030a;
border-radius: 1.75rem;
background: #03050b;
box-shadow: 0 1rem 2.5rem rgb(0 0 0 / 0.45);
}
canvas {
display: block;
width: 100%;
height: 100%;
background: #05070d;
}
How Does the Preview Keep Its Shape?
The phone frame fixes the visible area to a 9 / 16 aspect ratio. The canvas fills that frame without changing its internal 360 × 640 pixel dimensions.
- Save styles.css.
- Refresh Microsoft Edge.
You will see a tall phone-shaped canvas centered in the preview panel.
- Add the empty-state plus transport rules below the canvas styles with this code:
.empty-state {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 0.4rem;
padding: 2rem;
color: var(--muted);
text-align: center;
pointer-events: none;
}
.empty-state strong {
color: #f7f8ff;
}
.transport,
.trim-grid {
display: flex;
align-items: center;
gap: 0.75rem;
}
.transport {
justify-content: space-between;
margin-bottom: 0.75rem;
}
How Is the Empty State Positioned?
Absolute positioning places the empty message over the full canvas. The transport rules align the playback button with the time readout.
- Save styles.css.
- Refresh Microsoft Edge.
You will see the empty-state message centered inside the phone frame.
- Add the trim plus control-row rules below the transport styles with this code:
.trim-grid > * {
flex: 1;
}
.control-row {
display: grid;
gap: 0.45rem;
color: #d9deeb;
font-size: 0.92rem;
}
.control-row input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.control-row input[type="text"] {
width: 100%;
border: 1px solid var(--line);
border-radius: 0.65rem;
padding: 0.7rem 0.8rem;
background: #0c0f18;
color: white;
}
How Are the Editing Inputs Organized?
Each control row stacks its label above its input. The trim controls share the available width equally.
- Save styles.css.
- Refresh Microsoft Edge.
You will see full-width sliders plus a dark caption input.
- Add the file-picker styles below the control-row rules with this code:
.file-picker {
display: grid;
gap: 0.55rem;
margin-bottom: 1rem;
border: 1px dashed #58627a;
padding: 1rem;
background: #0d111c;
color: #e9ecf5;
}
.file-picker input {
width: 100%;
}
Why Does the Picker Stand Out?
The dashed border separates the active file-selection area from disabled editing controls. Its full-width input leaves room for the chosen file name.
- Save styles.css.
- Refresh Microsoft Edge.
You will see the local-video picker inside a dashed container.
- Add the fieldset plus export-box rules below the picker styles with this code:
fieldset {
display: grid;
gap: 0.9rem;
margin: 0 0 1rem;
border: 1px solid var(--line);
border-radius: 0.9rem;
padding: 1rem;
}
legend {
padding: 0 0.4rem;
color: var(--accent-light);
font-weight: 750;
}
.export-box {
display: grid;
gap: 0.65rem;
margin-bottom: 1rem;
border: 1px solid var(--line);
border-radius: 0.9rem;
padding: 1rem;
background: #0d111c;
}
How Are Related Controls Grouped?
Fieldsets keep effects separate from the caption layer. The export box creates another distinct surface for status plus download output.
- Save styles.css.
- Refresh Microsoft Edge.
You will see bordered effect, caption, plus export groups.
- Add the download-link plus inspector rules below the export-box styles with this code:
.export-box p {
margin: 0;
}
.download-link {
display: block;
border: 1px solid #4d9c7c;
padding: 0.7rem 0.9rem;
color: #9ff0cb;
text-align: center;
text-decoration: none;
}
.inspector {
border: 1px solid var(--line);
border-radius: 0.9rem;
padding: 1rem;
background: #090c14;
}
.inspector dl {
display: grid;
gap: 0.25rem;
margin: 0.8rem 0 0;
}
How Is Output Separated From Input?
The inspector uses a darker surface for technical measurements. The download link has a distinct success color when JavaScript reveals it later.
- Save styles.css.
- Refresh Microsoft Edge.
You will see the inspector as a dark bordered panel below the export area.
- Add the inspector-row plus utility rules below the inspector styles with this code:
.inspector dl div {
display: flex;
justify-content: space-between;
gap: 1rem;
border-top: 1px solid #22293a;
padding: 0.55rem 0;
}
.inspector dt {
color: var(--muted);
}
.inspector dd {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
text-align: right;
}
.muted {
color: var(--muted);
font-size: 0.85rem;
}
.source-video {
position: fixed;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
Why Hide the Source Video?
The browser still needs a video element for decoding plus timing. The editor displays processed frames on the canvas, so the source element stays visually hidden.
- Save styles.css.
- Refresh Microsoft Edge.
You will see labeled inspector rows with monospaced values aligned on the right.
- Add the hidden-state plus responsive rules below the utility styles with this code:
[hidden] {
display: none !important;
}
@media (max-width: 860px) {
.workspace {
grid-template-columns: 1fr;
}
.site-header {
align-items: start;
flex-direction: column;
gap: 0.5rem;
}
.site-header > p {
text-align: left;
}
}
How Does the Layout Respond?
The media query changes the workspace to one column below 860px. It also stacks the site header for a narrower browser window.
- Save styles.css.
- Refresh Microsoft Edge.
- Narrow the browser window until the panels stack vertically.
You will see the preview above the control panel when the browser becomes narrow.
Does the Page Still Look Unstyled?
Confirm that styles.css sits beside index.html in framelab. Check that the stylesheet link still uses href="styles.css".
Look for a missing closing brace near the last selector you added. Help me diagnose my FrameLab stylesheet.
Connect the JavaScript and verify
The first JavaScript layer only discovers the controls plus establishes their initial state. Clip loading, rendering, pixel effects, trimming, plus export remain inactive until later steps.
- Select app.js in the Explorer.
- Add the timeline constant plus preview-control queries with this code:
const TIMELINE_MAX = 1000;
const fileInput = document.querySelector("#videoFile");
const video = document.querySelector("#sourceVideo");
const canvas = document.querySelector("#previewCanvas");
const ctx = canvas.getContext("2d");
const emptyState = document.querySelector("#emptyState");
const playButton = document.querySelector("#playButton");
const playheadInput = document.querySelector("#playhead");
const trimStartInput = document.querySelector("#trimStart");
const trimEndInput = document.querySelector("#trimEnd");
const timeReadout = document.querySelector("#timeReadout");
const trimReadout = document.querySelector("#trimReadout");
const brightnessInput = document.querySelector("#brightness");
const saturationInput = document.querySelector("#saturation");
const brightnessValue = document.querySelector("#brightnessValue");
const saturationValue = document.querySelector("#saturationValue");
const compareButton = document.querySelector("#compareButton");
const overlayTextInput = document.querySelector("#overlayText");
What Does This JavaScript Find?
- The TIMELINE_MAX constant matches the normalized slider maximum.
- The canvas query gives JavaScript access to previewCanvas.
- The ctx reference exposes the canvas drawing context for later steps.
- The remaining queries connect transport, trim, plus effect controls to named variables.
- Save app.js.
- Refresh Microsoft Edge.
The editor remains visible with the same disabled controls. This confirms that the first query group loads without interrupting the page.
- Add the export plus inspector queries below overlayTextInput with this code:
const exportButton = document.querySelector("#exportButton");
const downloadLink = document.querySelector("#downloadLink");
const statusMessage = document.querySelector("#statusMessage");
const sourceDimensions = document.querySelector("#sourceDimensions");
const callbackMode = document.querySelector("#callbackMode");
const mediaTimeValue = document.querySelector("#mediaTimeValue");
const frameValue = document.querySelector("#frameValue");
const pixelValue = document.querySelector("#pixelValue");
const processingValue = document.querySelector("#processingValue");
const outputValue = document.querySelector("#outputValue");
const videoControls = document.querySelectorAll(".requires-video");
const supportsVideoFrameCallback =
"requestVideoFrameCallback" in HTMLVideoElement.prototype;
let trimStart = 0;
let trimEnd = 0;
Why Detect the Frame Callback Now?
The feature check records whether the browser exposes requestVideoFrameCallback. A later step uses that result to choose frame-synchronized rendering or a fallback.
The videoControls collection gathers every element that depends on a loaded clip.
- Save app.js.
- Refresh Microsoft Edge.
The status still reads Choose a local video to begin. while the media-dependent controls remain disabled.
- Add the initial-state plus time-formatting helpers below trimEnd with this code:
function setVideoControlsDisabled(disabled) {
videoControls.forEach((control) => {
control.disabled = disabled;
});
}
function formatTime(seconds) {
if (!Number.isFinite(seconds)) {
return "0:00.00";
}
const minutes = Math.floor(seconds / 60);
const remaining = (seconds % 60).toFixed(2).padStart(5, "0");
return `${minutes}:${remaining}`;
}
function timeToSlider(time) {
return Math.round((time / video.duration) * TIMELINE_MAX);
}
What Do These Helpers Prepare?
- The disabled-state helper updates every media-dependent control together.
- The time formatter converts seconds into the readout format used by the interface.
- The slider helper maps media time back onto the normalized timeline.
- Save app.js.
- Refresh Microsoft Edge.
You will still see 0:00.00 values because no clip has supplied a duration.
- Add the readout updates plus initial calls below timeToSlider() with this code:
function updateTimelineReadouts() {
const current = Number.isFinite(video.currentTime) ? video.currentTime : 0;
const duration = Number.isFinite(video.duration) ? video.duration : 0;
timeReadout.textContent = `${formatTime(current)} / ${formatTime(duration)}`;
trimReadout.textContent =
`Selected range: ${formatTime(trimStart)} to ${formatTime(trimEnd)}`;
if (duration > 0 && !playheadInput.matches(":active")) {
playheadInput.value = String(timeToSlider(current));
}
}
function updateEffectLabels() {
brightnessValue.textContent = brightnessInput.value;
saturationValue.textContent = `${saturationInput.value}%`;
}
setVideoControlsDisabled(true);
updateEffectLabels();
updateTimelineReadouts();
What Happens When the Script Loads?
- The timeline helper fills both time readouts from the current media state.
- The effect helper mirrors each slider's starting value beside its label.
- The three final calls establish the shell before any clip behavior exists.
- Save app.js.
Does the Page Turn Blank?
Check every selector against the matching ID in index.html. A missing quote or parenthesis in app.js can stop the initial calls.
Confirm that the script tag remains immediately above </body>. Help me diagnose my FrameLab JavaScript shell.
Your three files should now match the complete shell. Use this comparison before the final browser check.
✔️ Awesome, I've got everything!
Great. Save all three files before returning to Microsoft Edge.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FrameLab Video Editor</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="site-header">
<div>
<p class="eyebrow">Browser media laboratory</p>
<h1>FrameLab</h1>
</div>
<p>Inspect every frame from local file to exported video.</p>
</header>
<main class="workspace">
<section class="preview-panel" aria-labelledby="preview-heading">
<div class="section-heading">
<div>
<p class="eyebrow">Output canvas</p>
<h2 id="preview-heading">9:16 Preview</h2>
</div>
<span class="badge">360 × 640</span>
</div>
<div class="phone-frame">
<canvas id="previewCanvas" width="360" height="640"></canvas>
<div id="emptyState" class="empty-state">
<strong>No clip loaded</strong>
<span>Choose a short local video to begin.</span>
</div>
</div>
<div class="transport">
<button id="playButton" class="secondary requires-video" type="button" disabled>Play</button>
<span id="timeReadout">0:00.00 / 0:00.00</span>
</div>
<label class="control-row">
<span>Playhead</span>
<input id="playhead" class="requires-video" type="range" min="0" max="1000" value="0" disabled>
</label>
<div class="trim-grid">
<label class="control-row">
<span>Start</span>
<input id="trimStart" class="requires-video" type="range" min="0" max="1000" value="0" disabled>
</label>
<label class="control-row">
<span>End</span>
<input id="trimEnd" class="requires-video" type="range" min="0" max="1000" value="1000" disabled>
</label>
</div>
<p id="trimReadout" class="muted">Selected range: 0:00.00 to 0:00.00</p>
</section>
<section class="controls-panel" aria-labelledby="controls-heading">
<div class="section-heading">
<div>
<p class="eyebrow">Editor controls</p>
<h2 id="controls-heading">Process the Clip</h2>
</div>
</div>
<label class="file-picker">
<span>Choose local video</span>
<input id="videoFile" type="file" accept="video/*">
</label>
<fieldset>
<legend>Pixel effects</legend>
<label class="control-row">
<span>Brightness <output id="brightnessValue">0</output></span>
<input id="brightness" class="requires-video" type="range" min="-80" max="80" value="0" disabled>
</label>
<label class="control-row">
<span>Saturation <output id="saturationValue">100%</output></span>
<input id="saturation" class="requires-video" type="range" min="0" max="200" value="100" disabled>
</label>
<button id="compareButton" class="secondary requires-video" type="button" disabled>Hold to Compare Original</button>
</fieldset>
<fieldset>
<legend>Caption layer</legend>
<label class="control-row">
<span>Overlay text</span>
<input id="overlayText" class="requires-video" type="text" maxlength="48" value="Built frame by frame" disabled>
</label>
</fieldset>
<div class="export-box">
<button id="exportButton" class="primary requires-video" type="button" disabled>Export Trimmed Clip</button>
<a id="downloadLink" class="download-link" href="#" hidden>Download processed clip</a>
<p id="statusMessage" aria-live="polite">Choose a local video to begin.</p>
<p class="muted">Export is video-only and runs in real time.</p>
</div>
<section class="inspector" aria-labelledby="inspector-heading">
<h3 id="inspector-heading">Media Inspector</h3>
<dl>
<div><dt>Source</dt><dd id="sourceDimensions">Not loaded</dd></div>
<div><dt>Frame callback</dt><dd id="callbackMode">Not started</dd></div>
<div><dt>Media time</dt><dd id="mediaTimeValue">0.000 s</dd></div>
<div><dt>Presented frame</dt><dd id="frameValue">0</dd></div>
<div><dt>Center pixel</dt><dd id="pixelValue">rgba(0, 0, 0, 0)</dd></div>
<div><dt>Processing</dt><dd id="processingValue">0.00 ms</dd></div>
<div><dt>Export</dt><dd id="outputValue">Not exported</dd></div>
</dl>
</section>
</section>
</main>
<video id="sourceVideo" class="source-video" preload="metadata" playsinline></video>
<script src="app.js"></script>
</body>
</html>
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background: #090b12;
color: #f7f8ff;
--panel: #121622;
--panel-light: #1a2030;
--line: #30384d;
--muted: #9aa6bd;
--accent: #8b5cf6;
--accent-light: #b9a3ff;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at top left, #24204a 0, transparent 32rem),
#090b12;
}
button,
input {
font: inherit;
}
button,
.file-picker,
.download-link {
border-radius: 0.75rem;
}
button {
border: 1px solid transparent;
padding: 0.75rem 1rem;
cursor: pointer;
}
button:disabled,
input:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.primary {
background: var(--accent);
color: white;
font-weight: 700;
}
.secondary {
border-color: var(--line);
background: var(--panel-light);
color: #f7f8ff;
}
.site-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 2rem;
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 1rem;
}
.site-header h1,
.section-heading h2,
.inspector h3,
.site-header p {
margin: 0;
}
.site-header > p {
color: var(--muted);
text-align: right;
}
.eyebrow {
margin: 0 0 0.35rem;
color: var(--accent-light);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.workspace {
display: grid;
grid-template-columns: minmax(340px, 0.9fr) minmax(420px, 1.1fr);
gap: 1rem;
width: min(1180px, calc(100% - 2rem));
margin: 0 auto 2rem;
}
.preview-panel,
.controls-panel {
border: 1px solid var(--line);
border-radius: 1.25rem;
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.25);
}
.preview-panel {
padding: 1.25rem;
}
.controls-panel {
padding: 1.25rem;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.badge {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.4rem 0.65rem;
color: var(--muted);
font-size: 0.8rem;
}
.phone-frame {
position: relative;
width: min(100%, 360px);
aspect-ratio: 9 / 16;
margin: 0 auto 1rem;
overflow: hidden;
border: 8px solid #02030a;
border-radius: 1.75rem;
background: #03050b;
box-shadow: 0 1rem 2.5rem rgb(0 0 0 / 0.45);
}
canvas {
display: block;
width: 100%;
height: 100%;
background: #05070d;
}
.empty-state {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 0.4rem;
padding: 2rem;
color: var(--muted);
text-align: center;
pointer-events: none;
}
.empty-state strong {
color: #f7f8ff;
}
.transport,
.trim-grid {
display: flex;
align-items: center;
gap: 0.75rem;
}
.transport {
justify-content: space-between;
margin-bottom: 0.75rem;
}
.trim-grid > * {
flex: 1;
}
.control-row {
display: grid;
gap: 0.45rem;
color: #d9deeb;
font-size: 0.92rem;
}
.control-row input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.control-row input[type="text"] {
width: 100%;
border: 1px solid var(--line);
border-radius: 0.65rem;
padding: 0.7rem 0.8rem;
background: #0c0f18;
color: white;
}
.file-picker {
display: grid;
gap: 0.55rem;
margin-bottom: 1rem;
border: 1px dashed #58627a;
padding: 1rem;
background: #0d111c;
color: #e9ecf5;
}
.file-picker input {
width: 100%;
}
fieldset {
display: grid;
gap: 0.9rem;
margin: 0 0 1rem;
border: 1px solid var(--line);
border-radius: 0.9rem;
padding: 1rem;
}
legend {
padding: 0 0.4rem;
color: var(--accent-light);
font-weight: 750;
}
.export-box {
display: grid;
gap: 0.65rem;
margin-bottom: 1rem;
border: 1px solid var(--line);
border-radius: 0.9rem;
padding: 1rem;
background: #0d111c;
}
.export-box p {
margin: 0;
}
.download-link {
display: block;
border: 1px solid #4d9c7c;
padding: 0.7rem 0.9rem;
color: #9ff0cb;
text-align: center;
text-decoration: none;
}
.inspector {
border: 1px solid var(--line);
border-radius: 0.9rem;
padding: 1rem;
background: #090c14;
}
.inspector dl {
display: grid;
gap: 0.25rem;
margin: 0.8rem 0 0;
}
.inspector dl div {
display: flex;
justify-content: space-between;
gap: 1rem;
border-top: 1px solid #22293a;
padding: 0.55rem 0;
}
.inspector dt {
color: var(--muted);
}
.inspector dd {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
text-align: right;
}
.muted {
color: var(--muted);
font-size: 0.85rem;
}
.source-video {
position: fixed;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
[hidden] {
display: none !important;
}
@media (max-width: 860px) {
.workspace {
grid-template-columns: 1fr;
}
.site-header {
align-items: start;
flex-direction: column;
gap: 0.5rem;
}
.site-header > p {
text-align: left;
}
}
const TIMELINE_MAX = 1000;
const fileInput = document.querySelector("#videoFile");
const video = document.querySelector("#sourceVideo");
const canvas = document.querySelector("#previewCanvas");
const ctx = canvas.getContext("2d");
const emptyState = document.querySelector("#emptyState");
const playButton = document.querySelector("#playButton");
const playheadInput = document.querySelector("#playhead");
const trimStartInput = document.querySelector("#trimStart");
const trimEndInput = document.querySelector("#trimEnd");
const timeReadout = document.querySelector("#timeReadout");
const trimReadout = document.querySelector("#trimReadout");
const brightnessInput = document.querySelector("#brightness");
const saturationInput = document.querySelector("#saturation");
const brightnessValue = document.querySelector("#brightnessValue");
const saturationValue = document.querySelector("#saturationValue");
const compareButton = document.querySelector("#compareButton");
const overlayTextInput = document.querySelector("#overlayText");
const exportButton = document.querySelector("#exportButton");
const downloadLink = document.querySelector("#downloadLink");
const statusMessage = document.querySelector("#statusMessage");
const sourceDimensions = document.querySelector("#sourceDimensions");
const callbackMode = document.querySelector("#callbackMode");
const mediaTimeValue = document.querySelector("#mediaTimeValue");
const frameValue = document.querySelector("#frameValue");
const pixelValue = document.querySelector("#pixelValue");
const processingValue = document.querySelector("#processingValue");
const outputValue = document.querySelector("#outputValue");
const videoControls = document.querySelectorAll(".requires-video");
const supportsVideoFrameCallback =
"requestVideoFrameCallback" in HTMLVideoElement.prototype;
let trimStart = 0;
let trimEnd = 0;
function setVideoControlsDisabled(disabled) {
videoControls.forEach((control) => {
control.disabled = disabled;
});
}
function formatTime(seconds) {
if (!Number.isFinite(seconds)) {
return "0:00.00";
}
const minutes = Math.floor(seconds / 60);
const remaining = (seconds % 60).toFixed(2).padStart(5, "0");
return `${minutes}:${remaining}`;
}
function timeToSlider(time) {
return Math.round((time / video.duration) * TIMELINE_MAX);
}
function updateTimelineReadouts() {
const current = Number.isFinite(video.currentTime) ? video.currentTime : 0;
const duration = Number.isFinite(video.duration) ? video.duration : 0;
timeReadout.textContent = `${formatTime(current)} / ${formatTime(duration)}`;
trimReadout.textContent =
`Selected range: ${formatTime(trimStart)} to ${formatTime(trimEnd)}`;
if (duration > 0 && !playheadInput.matches(":active")) {
playheadInput.value = String(timeToSlider(current));
}
}
function updateEffectLabels() {
brightnessValue.textContent = brightnessInput.value;
saturationValue.textContent = `${saturationInput.value}%`;
}
setVideoControlsDisabled(true);
updateEffectLabels();
updateTimelineReadouts();
Before you refresh, which controls do you expect to remain usable without a loaded clip?
- Return to Microsoft Edge.
- Refresh the local index.html page.
You will see the FrameLab header plus a two-panel editor. The empty 360 × 640 preview will sit beside the file picker, disabled editing controls, export area, plus Media Inspector.
- Press Ctrl+Shift+I to open Microsoft Edge DevTools.
- Check the developer console for errors.
You should see no console errors. The status will read Choose a local video to begin..
That is the foundation in place: FrameLab now has a complete interface with a clean initial state. Next, you will load your local clip plus expose the stretching problem in the first moving preview.
Load and Preview Your Clip
Your Visual Studio Code shell already gives FrameLab a portrait Canvas plus the controls around it. The canvas still needs decoded video frames before it can show your clip.
This step connects your local file to the hidden video element. You will render a moving preview with direct scaling so you can inspect the result before improving its geometry.
In this step, get ready to:
- Load your local clip through a temporary object URL.
- Render decoded video frames into the portrait canvas.
- Control playback while observing the direct-scaling problem.
Load the local clip
A Blob URL gives the hidden video element temporary access to the file you select. FrameLab keeps references to that URL plus the playback state so it can replace old media safely.
- Switch back to Visual Studio Code.
- Select app.js in the Explorer sidebar.
- Find the supportsVideoFrameCallback declaration.
- Add this playback state directly below that declaration:
let sourceUrl = null;
let downloadUrl = null;
let trimStart = 0;
let trimEnd = 0;
let showOriginal = false;
let renderLoopActive = false;
let renderedFrames = 0;
let exporting = false;
let activeRecorder = null;
let activeStream = null;
let previousMutedState = false;
What Does This State Track?
- The URL variables identify temporary media addresses that FrameLab can revoke later.
- The trim variables initially cover the entire clip.
- The remaining values track rendering plus future comparison and export work.
- Save app.js.
- Refresh the FrameLab page in Microsoft Edge.
You should still see the empty canvas plus the initial status. The page should load without a console error.
- Find the closing brace of setVideoControlsDisabled().
- Add these display helpers below it:
function formatTime(seconds) {
if (!Number.isFinite(seconds)) {
return "0:00.00";
}
const minutes = Math.floor(seconds / 60);
const remaining = (seconds % 60).toFixed(2).padStart(5, "0");
return `${minutes}:${remaining}`;
}
function formatBytes(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
const kilobytes = bytes / 1024;
if (kilobytes < 1024) {
return `${kilobytes.toFixed(1)} KB`;
}
return `${(kilobytes / 1024).toFixed(2)} MB`;
}
What Do These Helpers Format?
- The formatTime() helper turns media seconds into the timestamp shown beside the Play button.
- The formatBytes() helper prepares byte counts for the export inspector later.
- Add the timeline conversion helpers below formatBytes().
- Add resetDownload() below the conversion helpers:
function sliderToTime(value) {
return (Number(value) / TIMELINE_MAX) * video.duration;
}
function timeToSlider(time) {
return Math.round((time / video.duration) * TIMELINE_MAX);
}
function resetDownload() {
if (downloadUrl) {
URL.revokeObjectURL(downloadUrl);
downloadUrl = null;
}
downloadLink.hidden = true;
downloadLink.removeAttribute("download");
downloadLink.href = "#";
outputValue.textContent = "Not exported";
}
How Does Normalized Time Work?
The timeline stores positions from 0 to 1000. The conversion helpers map that normalized range to the selected clip's duration.
The cleanup helper revokes an earlier download URL before FrameLab creates another one. This prevents obsolete browser-held URLs from accumulating.
- Save app.js.
- Refresh the FrameLab page in Microsoft Edge.
You should still see 0:00.00 / 0:00.00 beside the disabled Play button. The formatted timestamp confirms that the page still initializes correctly.
- Find the end of the existing function declarations in app.js.
- Add this file-selection listener below them:
fileInput.addEventListener("change", () => {
const file = fileInput.files[0];
if (!file) {
return;
}
if (sourceUrl) {
URL.revokeObjectURL(sourceUrl);
}
resetDownload();
video.pause();
sourceUrl = URL.createObjectURL(file);
video.src = sourceUrl;
video.load();
statusMessage.textContent = `Loading ${file.name}...`;
emptyState.hidden = false;
});
What Does the Selection Listener Do?
- The listener reads the selected File from the file input.
- The previous source URL is revoked before FrameLab creates a replacement.
- The new URL becomes the hidden video's source so Microsoft Edge can decode the local clip.
- Save app.js.
- Refresh the FrameLab page in Microsoft Edge.
- Select Choose local video.
- Choose your short local clip from the Windows file picker.
The status should change to a loading message containing your clip's filename. This proves that the local file reached the hidden video element.
Does the Loading Status Stay Visible?
Confirm that you selected a video file supported by Microsoft Edge. A file that cannot be decoded leaves FrameLab without usable metadata.
Check that video.src receives sourceUrl before video.load() runs.
Help me diagnose my local clip loading code.
- Add this metadata listener below the file-selection listener:
video.addEventListener("loadedmetadata", () => {
trimStart = 0;
trimEnd = video.duration;
trimStartInput.value = "0";
trimEndInput.value = String(TIMELINE_MAX);
playheadInput.value = "0";
renderedFrames = 0;
setVideoControlsDisabled(false);
sourceDimensions.textContent = `${video.videoWidth} × ${video.videoHeight}`;
callbackMode.textContent = supportsVideoFrameCallback
? "requestVideoFrameCallback"
: "requestAnimationFrame fallback";
statusMessage.textContent = "Clip loaded. Adjust the preview or press Play.";
updateTimelineReadouts();
});
What Happens When Metadata Loads?
- The duration establishes the initial trim range plus the normalized timeline positions.
- The intrinsic videoWidth plus videoHeight values populate the source inspector.
- The controls become available only after the clip has a usable duration.
- Save app.js.
- Refresh the FrameLab page in Microsoft Edge.
- Choose your local clip again.
You should see the source dimensions in the Media Inspector. The editing controls should also become enabled.
Render each decoded frame
A frame callback keeps canvas drawing close to the video's decoded frames. FrameLab uses the video-specific callback when available plus an animation-frame fallback for compatibility.
- Add this first version of drawProcessedFrame() below the metadata listener:
function drawProcessedFrame(metadata = null) {
if (video.readyState < 2 || video.videoWidth === 0) {
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
renderedFrames += 1;
const mediaTime = metadata?.mediaTime ?? video.currentTime;
const presentedFrame = metadata?.presentedFrames ?? renderedFrames;
mediaTimeValue.textContent = `${mediaTime.toFixed(3)} s`;
frameValue.textContent = String(presentedFrame);
emptyState.hidden = true;
}
video.addEventListener("loadeddata", () => {
drawProcessedFrame();
});
What Does the First Renderer Do?
- The readiness check waits until the hidden video has a decoded frame plus intrinsic dimensions.
- The five-argument drawImage() call fills the complete 360 × 640 canvas with the source frame.
- The metadata readouts expose media time plus the number of presented frames.
Before you reload the clip, do you think direct resizing preserves its original proportions?
- Save app.js.
- Refresh the FrameLab page in Microsoft Edge.
- Choose your local clip again.
You should see the first decoded frame filling the portrait canvas. A landscape or differently proportioned source looks compressed into the canvas shape.
- Add the callback scheduler plus the video-frame handler below the loaded-data listener:
function scheduleNextFrame() {
if (!renderLoopActive) {
return;
}
if (supportsVideoFrameCallback) {
video.requestVideoFrameCallback(renderVideoFrame);
} else {
requestAnimationFrame(renderAnimationFrame);
}
}
function renderVideoFrame(now, metadata) {
if (!renderLoopActive) {
return;
}
drawProcessedFrame(metadata);
if (video.paused || video.ended) {
renderLoopActive = false;
return;
}
scheduleNextFrame();
}
How Does the Preferred Callback Work?
The scheduler checks whether Microsoft Edge supports requestVideoFrameCallback(). Supported browsers pass media timing metadata into the renderer.
Each handler schedules the next frame only while playback remains active. This keeps the loop tied to the media state.
- Add the fallback renderer plus the loop starter below renderVideoFrame().
function renderAnimationFrame() {
if (!renderLoopActive) {
return;
}
drawProcessedFrame({
mediaTime: video.currentTime,
presentedFrames: renderedFrames,
width: video.videoWidth,
height: video.videoHeight,
});
if (video.paused || video.ended) {
renderLoopActive = false;
return;
}
scheduleNextFrame();
}
function startRenderLoop() {
if (renderLoopActive) {
return;
}
renderLoopActive = true;
scheduleNextFrame();
}
How Does the Fallback Stay Useful?
The fallback reads timing values from the video element before each browser repaint. It then passes those values through the same canvas renderer.
The loop starter prevents duplicate callback chains. One active chain is enough to keep the preview moving.
- Save app.js.
- Refresh the FrameLab page in Microsoft Edge.
- Choose your local clip again.
The Media Inspector should identify the selected callback mode. The canvas should still show the first decoded frame.
Control playback and inspect the distortion
Playback needs a safe starting point inside the selected range. The Play button can then start or pause the hidden video while its events keep the visible controls synchronized.
- Add seekTo() below startRenderLoop().
function seekTo(time) {
const clampedTime = Math.max(0, Math.min(video.duration, time));
if (Math.abs(video.currentTime - clampedTime) < 0.001) {
return Promise.resolve();
}
return new Promise((resolve) => {
video.addEventListener("seeked", resolve, { once: true });
video.currentTime = clampedTime;
});
}
Why Wait for Seeking?
Changing currentTime asks the media element to seek. The returned promise resolves after the requested position is ready.
Clamping keeps the requested time within the clip's duration. This gives playback a valid frame to start from.
- Add togglePlayback() below seekTo().
async function togglePlayback() {
if (video.paused) {
if (video.currentTime < trimStart || video.currentTime >= trimEnd - 0.02) {
await seekTo(trimStart);
}
try {
await video.play();
} catch (error) {
statusMessage.textContent = `Playback could not start: ${error.message}`;
}
} else {
video.pause();
}
}
How Does the Button Choose an Action?
A paused clip starts from a valid point inside the current trim range. An active clip pauses immediately.
The playback promise is caught so a browser rejection becomes a visible FrameLab status. The interface remains responsive if playback cannot begin.
- Add these playback listeners below togglePlayback().
playButton.addEventListener("click", togglePlayback);
video.addEventListener("seeked", () => {
drawProcessedFrame();
updateTimelineReadouts();
});
video.addEventListener("play", () => {
playButton.textContent = "Pause";
startRenderLoop();
});
video.addEventListener("pause", () => {
playButton.textContent = "Play";
updateTimelineReadouts();
});
video.addEventListener("timeupdate", () => {
updateTimelineReadouts();
});
What Keeps the Interface Synchronized?
- The click listener sends the Play button through one playback function.
- The media events redraw completed seeks plus update the button label.
- The time-update event refreshes the visible timestamp plus the normalized playhead.
- Save app.js.
- Refresh the FrameLab page in Microsoft Edge.
- Choose your local clip again.
- Select Play.
Your clip should now move inside the canvas. The timestamp plus presented-frame readout should update during playback.
- Add the remaining media plus playhead listeners below the time-update listener:
video.addEventListener("ended", () => {
video.currentTime = trimStart;
});
video.addEventListener("error", () => {
setVideoControlsDisabled(true);
statusMessage.textContent = "Microsoft Edge could not decode this video file.";
});
playheadInput.addEventListener("input", () => {
video.pause();
const target = Math.max(
trimStart,
Math.min(trimEnd, sliderToTime(playheadInput.value)),
);
playheadInput.value = String(timeToSlider(target));
video.currentTime = Math.min(target, video.duration - 0.001);
});
window.addEventListener("beforeunload", () => {
if (sourceUrl) {
URL.revokeObjectURL(sourceUrl);
}
if (downloadUrl) {
URL.revokeObjectURL(downloadUrl);
}
if (activeStream) {
activeStream.getTracks().forEach((track) => track.stop());
}
});
What Do the Final Listeners Protect?
- The ended listener returns the playhead to the beginning of the selected range.
- The error listener disables media controls when Microsoft Edge cannot decode the source.
- The playhead listener converts its normalized value into media seconds before seeking.
- The unload listener releases temporary URLs plus any future active media stream.
Before the final check, do you expect the moving source to keep its original proportions inside the portrait canvas?
- Save app.js.
- Refresh the FrameLab page in Microsoft Edge.
- Choose your local clip again.
- Select Play.
- Drag the Playhead slider to another point in the clip.
You should see moving video plus a live media timestamp. The source is visibly stretched to fill the 9:16 canvas because the direct draw uses the canvas width plus height independently.
Why Is the Stretching Useful?
This planned shortfall proves that direct width-plus-height scaling ignores the source aspect ratio. You now have a concrete geometry problem to solve.
Does the Preview Stay Still?
Confirm that the Play button changes to Pause. If it does not, check the click listener plus togglePlayback().
Confirm that the play event calls startRenderLoop(). Also check that each frame handler calls scheduleNextFrame().
Help me debug my moving canvas preview.
✔️ Awesome, I've got everything!
Your saved app.js now loads local media plus renders a moving stretched preview. Your existing index.html plus styles.css remain unchanged.
ⓧ I'd like to double check the full code
Compare your cumulative app.js with this version. Keep the deliberate direct-scaling renderer for this step.
const TIMELINE_MAX = 1000;
const fileInput = document.querySelector("#videoFile");
const video = document.querySelector("#sourceVideo");
const canvas = document.querySelector("#previewCanvas");
const ctx = canvas.getContext("2d");
const emptyState = document.querySelector("#emptyState");
const playButton = document.querySelector("#playButton");
const playheadInput = document.querySelector("#playhead");
const trimStartInput = document.querySelector("#trimStart");
const trimEndInput = document.querySelector("#trimEnd");
const timeReadout = document.querySelector("#timeReadout");
const trimReadout = document.querySelector("#trimReadout");
const brightnessInput = document.querySelector("#brightness");
const saturationInput = document.querySelector("#saturation");
const brightnessValue = document.querySelector("#brightnessValue");
const saturationValue = document.querySelector("#saturationValue");
const compareButton = document.querySelector("#compareButton");
const overlayTextInput = document.querySelector("#overlayText");
const exportButton = document.querySelector("#exportButton");
const downloadLink = document.querySelector("#downloadLink");
const statusMessage = document.querySelector("#statusMessage");
const sourceDimensions = document.querySelector("#sourceDimensions");
const callbackMode = document.querySelector("#callbackMode");
const mediaTimeValue = document.querySelector("#mediaTimeValue");
const frameValue = document.querySelector("#frameValue");
const pixelValue = document.querySelector("#pixelValue");
const processingValue = document.querySelector("#processingValue");
const outputValue = document.querySelector("#outputValue");
const videoControls = document.querySelectorAll(".requires-video");
const supportsVideoFrameCallback =
"requestVideoFrameCallback" in HTMLVideoElement.prototype;
let sourceUrl = null;
let downloadUrl = null;
let trimStart = 0;
let trimEnd = 0;
let showOriginal = false;
let renderLoopActive = false;
let renderedFrames = 0;
let exporting = false;
let activeRecorder = null;
let activeStream = null;
let previousMutedState = false;
function setVideoControlsDisabled(disabled) {
videoControls.forEach((control) => {
control.disabled = disabled;
});
}
function formatTime(seconds) {
if (!Number.isFinite(seconds)) {
return "0:00.00";
}
const minutes = Math.floor(seconds / 60);
const remaining = (seconds % 60).toFixed(2).padStart(5, "0");
return `${minutes}:${remaining}`;
}
function formatBytes(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
const kilobytes = bytes / 1024;
if (kilobytes < 1024) {
return `${kilobytes.toFixed(1)} KB`;
}
return `${(kilobytes / 1024).toFixed(2)} MB`;
}
function sliderToTime(value) {
return (Number(value) / TIMELINE_MAX) * video.duration;
}
function timeToSlider(time) {
return Math.round((time / video.duration) * TIMELINE_MAX);
}
function updateTimelineReadouts() {
const current = Number.isFinite(video.currentTime) ? video.currentTime : 0;
const duration = Number.isFinite(video.duration) ? video.duration : 0;
timeReadout.textContent = `${formatTime(current)} / ${formatTime(duration)}`;
trimReadout.textContent =
`Selected range: ${formatTime(trimStart)} to ${formatTime(trimEnd)}`;
if (duration > 0 && !playheadInput.matches(":active")) {
playheadInput.value = String(timeToSlider(current));
}
}
function resetDownload() {
if (downloadUrl) {
URL.revokeObjectURL(downloadUrl);
downloadUrl = null;
}
downloadLink.hidden = true;
downloadLink.removeAttribute("download");
downloadLink.href = "#";
outputValue.textContent = "Not exported";
}
function drawProcessedFrame(metadata = null) {
if (video.readyState < 2 || video.videoWidth === 0) {
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
renderedFrames += 1;
const mediaTime = metadata?.mediaTime ?? video.currentTime;
const presentedFrame = metadata?.presentedFrames ?? renderedFrames;
mediaTimeValue.textContent = `${mediaTime.toFixed(3)} s`;
frameValue.textContent = String(presentedFrame);
emptyState.hidden = true;
}
function scheduleNextFrame() {
if (!renderLoopActive) {
return;
}
if (supportsVideoFrameCallback) {
video.requestVideoFrameCallback(renderVideoFrame);
} else {
requestAnimationFrame(renderAnimationFrame);
}
}
function renderVideoFrame(now, metadata) {
if (!renderLoopActive) {
return;
}
drawProcessedFrame(metadata);
if (video.paused || video.ended) {
renderLoopActive = false;
return;
}
scheduleNextFrame();
}
function renderAnimationFrame() {
if (!renderLoopActive) {
return;
}
drawProcessedFrame({
mediaTime: video.currentTime,
presentedFrames: renderedFrames,
width: video.videoWidth,
height: video.videoHeight,
});
if (video.paused || video.ended) {
renderLoopActive = false;
return;
}
scheduleNextFrame();
}
function startRenderLoop() {
if (renderLoopActive) {
return;
}
renderLoopActive = true;
scheduleNextFrame();
}
function seekTo(time) {
const clampedTime = Math.max(0, Math.min(video.duration, time));
if (Math.abs(video.currentTime - clampedTime) < 0.001) {
return Promise.resolve();
}
return new Promise((resolve) => {
video.addEventListener("seeked", resolve, { once: true });
video.currentTime = clampedTime;
});
}
async function togglePlayback() {
if (video.paused) {
if (video.currentTime < trimStart || video.currentTime >= trimEnd - 0.02) {
await seekTo(trimStart);
}
try {
await video.play();
} catch (error) {
statusMessage.textContent = `Playback could not start: ${error.message}`;
}
} else {
video.pause();
}
}
function updateEffectLabels() {
brightnessValue.textContent = brightnessInput.value;
saturationValue.textContent = `${saturationInput.value}%`;
}
fileInput.addEventListener("change", () => {
const file = fileInput.files[0];
if (!file) {
return;
}
if (sourceUrl) {
URL.revokeObjectURL(sourceUrl);
}
resetDownload();
video.pause();
sourceUrl = URL.createObjectURL(file);
video.src = sourceUrl;
video.load();
statusMessage.textContent = `Loading ${file.name}...`;
emptyState.hidden = false;
});
video.addEventListener("loadedmetadata", () => {
trimStart = 0;
trimEnd = video.duration;
trimStartInput.value = "0";
trimEndInput.value = String(TIMELINE_MAX);
playheadInput.value = "0";
renderedFrames = 0;
setVideoControlsDisabled(false);
sourceDimensions.textContent = `${video.videoWidth} × ${video.videoHeight}`;
callbackMode.textContent = supportsVideoFrameCallback
? "requestVideoFrameCallback"
: "requestAnimationFrame fallback";
statusMessage.textContent = "Clip loaded. Adjust the preview or press Play.";
updateTimelineReadouts();
});
video.addEventListener("loadeddata", () => {
drawProcessedFrame();
});
video.addEventListener("seeked", () => {
drawProcessedFrame();
updateTimelineReadouts();
});
video.addEventListener("play", () => {
playButton.textContent = "Pause";
startRenderLoop();
});
video.addEventListener("pause", () => {
playButton.textContent = "Play";
updateTimelineReadouts();
});
video.addEventListener("timeupdate", () => {
updateTimelineReadouts();
});
video.addEventListener("ended", () => {
video.currentTime = trimStart;
});
video.addEventListener("error", () => {
setVideoControlsDisabled(true);
statusMessage.textContent = "Microsoft Edge could not decode this video file.";
});
playButton.addEventListener("click", togglePlayback);
playheadInput.addEventListener("input", () => {
video.pause();
const target = Math.max(
trimStart,
Math.min(trimEnd, sliderToTime(playheadInput.value)),
);
playheadInput.value = String(timeToSlider(target));
video.currentTime = Math.min(target, video.duration - 0.001);
});
window.addEventListener("beforeunload", () => {
if (sourceUrl) {
URL.revokeObjectURL(sourceUrl);
}
if (downloadUrl) {
URL.revokeObjectURL(downloadUrl);
}
if (activeStream) {
activeStream.getTracks().forEach((track) => track.stop());
}
});
setVideoControlsDisabled(true);
updateEffectLabels();
updateTimelineReadouts();
You have a frame-synchronized local preview plus working playback controls. Next, you will replace the stretching with center-crop geometry plus a visible pixel-processing pipeline.
Build the Pixel Pipeline
Your clip already plays in Microsoft Edge. The stretched preview proves that matching the canvas width plus height can destroy the source proportions.
This step replaces that stretch with a centered cover crop. You will also expose the RGBA values behind each frame for mathematical color processing.
In this step, get ready to:
- Preserve the clip proportions with cover-crop geometry.
- Transform each pixel with brightness plus saturation math.
- Compare the processed frame with its original pixels.
Preserve proportions with a cover crop
A cover crop scales the source until it fills the Canvas. The excess width or height is removed equally from both sides.
- Switch back to Visual Studio Code.
- Select app.js in the Explorer sidebar.
- Find the existing drawProcessedFrame function.
- Add this function directly above drawProcessedFrame:
function drawCover() {
const scale = Math.max(
canvas.width / video.videoWidth,
canvas.height / video.videoHeight,
);
const sourceWidth = canvas.width / scale;
const sourceHeight = canvas.height / scale;
const sourceX = (video.videoWidth - sourceWidth) / 2;
const sourceY = (video.videoHeight - sourceHeight) / 2;
ctx.drawImage(
video,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
canvas.width,
canvas.height,
);
}
How Does the Cover Crop Work?
- The scale uses the larger width or height ratio. This guarantees that the source fills the canvas.
- The source dimensions convert the canvas size back into video coordinates. These values define the section of the source that remains visible.
- The source offsets remove equal amounts from opposite edges. This keeps the crop centered.
- The nine-argument drawImage() call selects the source rectangle before scaling it into the full canvas.
- Find ctx.drawImage(video, 0, 0, canvas.width, canvas.height) inside drawProcessedFrame.
- Replace that stretch operation with this call:
drawCover();
What Changes in the Preview?
The render loop now sends every frame through the crop calculation. Each frame keeps the source proportions while filling the portrait canvas.
Before you check the crop, which edges do you expect FrameLab to remove from your clip?
- Save app.js.
- Switch back to Microsoft Edge.
- Refresh the FrameLab page.
- Select your local clip with the file control.
- Select Play.
You will see the moving clip fill the portrait canvas without stretching. The crop stays centered while the source proportions remain correct.
Does the Preview Stay Stretched?
Confirm that drawProcessedFrame calls drawCover(). Remove the previous five-argument drawing call.
Check that every crop calculation uses video.videoWidth plus video.videoHeight. Display dimensions cannot supply the intrinsic source geometry.
Help me debug the FrameLab cover crop.
Transform RGBA pixels
An ImageData frame stores four consecutive values for every pixel. The loop advances by four so each pass handles one red, green, blue, plus alpha group.
- Return to app.js.
- Add these functions directly below updateTimelineReadouts:
function clampChannel(value) {
return Math.max(0, Math.min(255, Math.round(value)));
}
function applyPixelEffects() {
const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = frame.data;
const brightness = Number(brightnessInput.value);
const saturation = Number(saturationInput.value) / 100;
for (let index = 0; index < pixels.length; index += 4) {
const red = pixels[index];
const green = pixels[index + 1];
const blue = pixels[index + 2];
const gray = 0.299 * red + 0.587 * green + 0.114 * blue;
pixels[index] = clampChannel(
gray + (red - gray) * saturation + brightness,
);
pixels[index + 1] = clampChannel(
gray + (green - gray) * saturation + brightness,
);
pixels[index + 2] = clampChannel(
gray + (blue - gray) * saturation + brightness,
);
}
ctx.putImageData(frame, 0, 0);
}
What Does the Pixel Loop Do?
- The getImageData() call copies the full canvas frame into an editable pixel array.
- The weighted grayscale formula estimates brightness from the red, green, plus blue channels. Green receives the greatest weight.
- The saturation formula moves each channel toward or away from grayscale. A normalized value of 1 preserves the original saturation.
- The brightness value shifts every color channel. clampChannel() keeps the result inside the valid 0..255 range.
- The putImageData() call paints the transformed array back onto the canvas.
- Replace the existing drawProcessedFrame function with this processing version:
function drawProcessedFrame(metadata = null) {
if (video.readyState < 2 || video.videoWidth === 0) {
return;
}
const startedAt = performance.now();
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawCover();
let centerPixel;
centerPixel = applyPixelEffects();
renderedFrames += 1;
const elapsed = performance.now() - startedAt;
emptyState.hidden = true;
}
How Does Processing Join the Render Loop?
Each frame is cropped before applyPixelEffects() reads its pixels. The current slider values therefore affect every frame during playback.
The timestamps surround the complete draw plus processing pass. You will expose that elapsed time in the inspector shortly.
- Save app.js.
- Switch back to Microsoft Edge.
- Refresh FrameLab.
- Select your local clip again.
- Select Play.
- Move the Brightness slider while the clip plays.
- Move the Saturation slider while the clip plays.
You will see the canvas change as the render loop reads each slider value. Lower saturation moves the frame toward grayscale.
- Return to applyPixelEffects in app.js.
- Find the existing ctx.putImageData(frame, 0, 0) line.
- Replace that line with this center-pixel block:
const centerX = Math.floor(canvas.width / 2);
const centerY = Math.floor(canvas.height / 2);
const centerIndex = (centerY * canvas.width + centerX) * 4;
const centerPixel = [
pixels[centerIndex],
pixels[centerIndex + 1],
pixels[centerIndex + 2],
pixels[centerIndex + 3],
];
ctx.putImageData(frame, 0, 0);
return centerPixel;
Why Return the Center Pixel?
The center coordinates identify one pixel after the transformation. Multiplying its row position by the canvas width converts the two-dimensional location into an array offset.
Multiplying by four reaches the red value for that pixel. The next three values provide green, blue, plus alpha.
- Add these effect helpers below togglePlayback.
- Add the two slider listeners near the existing input listeners:
function updateEffectLabels() {
brightnessValue.textContent = brightnessInput.value;
saturationValue.textContent = `${saturationInput.value}%`;
}
function redrawPausedFrame() {
if (video.paused) {
drawProcessedFrame();
}
}
brightnessInput.addEventListener("input", () => {
updateEffectLabels();
redrawPausedFrame();
});
saturationInput.addEventListener("input", () => {
updateEffectLabels();
redrawPausedFrame();
});
What Do the Effect Listeners Add?
The labels now display the active brightness plus saturation values. A paused frame redraws immediately so the sliders remain useful without playback.
- Save app.js.
- Refresh FrameLab in Microsoft Edge.
- Select your local clip.
- Pause the clip on a detailed frame.
- Move the Brightness slider.
- Move the Saturation slider.
The paused frame will redraw after each change. The values beside both controls will follow their sliders.
Do the Sliders Leave the Frame Unchanged?
Confirm that applyPixelEffects() runs after drawCover(). Reversing that order paints the unprocessed video over the edited pixels.
Check that the loop advances with index += 4. A different step can misalign the color channels.
Help me debug the FrameLab pixel effects.
Add comparison and inspection
The compare control needs an untouched sample from the rendered crop. The inspector also needs one consistent place to receive frame metadata plus measured processing time.
- Add this original-pixel helper directly below applyPixelEffects.
- Add the pointer listeners near the slider listeners:
function readCenterPixel() {
const centerX = Math.floor(canvas.width / 2);
const centerY = Math.floor(canvas.height / 2);
return Array.from(ctx.getImageData(centerX, centerY, 1, 1).data);
}
compareButton.addEventListener("pointerdown", () => {
showOriginal = true;
drawProcessedFrame();
});
["pointerup", "pointercancel", "pointerleave"].forEach((eventName) => {
compareButton.addEventListener(eventName, () => {
showOriginal = false;
drawProcessedFrame();
});
});
How Does Hold-to-Compare Work?
Pressing the button sets showOriginal to true. Releasing or leaving the button restores the processed state.
The helper reads one pixel directly from the cropped canvas. This supplies the original RGBA sample when processing is bypassed.
- Replace drawProcessedFrame with this comparison-aware version:
function drawProcessedFrame(metadata = null) {
if (video.readyState < 2 || video.videoWidth === 0) {
return;
}
const startedAt = performance.now();
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawCover();
let centerPixel;
if (showOriginal) {
centerPixel = readCenterPixel();
} else {
centerPixel = applyPixelEffects();
}
renderedFrames += 1;
const elapsed = performance.now() - startedAt;
emptyState.hidden = true;
}
Where Does Comparison Branch?
Both paths begin with the same centered crop. The original path reads its sample without running the transformation.
The processed path applies the color formulas before returning its sample. This isolates the effect transform as the only difference.
- Save app.js.
- Refresh FrameLab in Microsoft Edge.
- Select your clip.
- Set a visible brightness or saturation change.
- Hold Hold to Compare Original.
- Release the comparison button.
The original crop will appear while you hold the button. The processed frame will return when you release it.
- Add this inspector function directly above drawProcessedFrame.
function updateInspector(metadata, centerPixel, elapsed) {
const mediaTime = metadata?.mediaTime ?? video.currentTime;
const presentedFrame = metadata?.presentedFrames ?? renderedFrames;
sourceDimensions.textContent = `${video.videoWidth} × ${video.videoHeight}`;
callbackMode.textContent = supportsVideoFrameCallback
? "requestVideoFrameCallback"
: "requestAnimationFrame fallback";
mediaTimeValue.textContent = `${mediaTime.toFixed(3)} s`;
frameValue.textContent = String(presentedFrame);
pixelValue.textContent =
`rgba(${centerPixel[0]}, ${centerPixel[1]}, ${centerPixel[2]}, ${centerPixel[3]})`;
processingValue.textContent = `${elapsed.toFixed(2)} ms`;
}
What Does the Inspector Report?
- The callback mode shows which frame scheduler the browser selected.
- The media time plus presented-frame values come from callback metadata when available.
- The center-pixel value exposes the current RGBA sample.
- The processing value reports the measured duration of the crop plus pixel pass.
- Replace drawProcessedFrame one final time with this inspected version:
function drawProcessedFrame(metadata = null) {
if (video.readyState < 2 || video.videoWidth === 0) {
return;
}
const startedAt = performance.now();
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawCover();
let centerPixel;
if (showOriginal) {
centerPixel = readCenterPixel();
} else {
centerPixel = applyPixelEffects();
}
renderedFrames += 1;
const elapsed = performance.now() - startedAt;
updateInspector(metadata, centerPixel, elapsed);
emptyState.hidden = true;
}
How Does the Full Frame Pass Flow?
The function clears the previous frame before drawing the centered crop. It then chooses the original or processed pixel path.
The final lines count the frame plus measure the pass. The inspector receives that evidence before the canvas becomes visible.
✔️ Awesome, I've got everything!
Great. Your saved app.js now contains the complete crop plus pixel-processing pipeline.
ⓧ I'd like to double check the full code
Compare your cumulative app.js with this reference. Keep the clip-loading plus frame-scheduling code from the previous step around these additions.
const TIMELINE_MAX = 1000;
const fileInput = document.querySelector("#videoFile");
const video = document.querySelector("#sourceVideo");
const canvas = document.querySelector("#previewCanvas");
const ctx = canvas.getContext("2d");
const emptyState = document.querySelector("#emptyState");
const playButton = document.querySelector("#playButton");
const playheadInput = document.querySelector("#playhead");
const trimStartInput = document.querySelector("#trimStart");
const trimEndInput = document.querySelector("#trimEnd");
const timeReadout = document.querySelector("#timeReadout");
const trimReadout = document.querySelector("#trimReadout");
const brightnessInput = document.querySelector("#brightness");
const saturationInput = document.querySelector("#saturation");
const brightnessValue = document.querySelector("#brightnessValue");
const saturationValue = document.querySelector("#saturationValue");
const compareButton = document.querySelector("#compareButton");
const overlayTextInput = document.querySelector("#overlayText");
const exportButton = document.querySelector("#exportButton");
const downloadLink = document.querySelector("#downloadLink");
const statusMessage = document.querySelector("#statusMessage");
const sourceDimensions = document.querySelector("#sourceDimensions");
const callbackMode = document.querySelector("#callbackMode");
const mediaTimeValue = document.querySelector("#mediaTimeValue");
const frameValue = document.querySelector("#frameValue");
const pixelValue = document.querySelector("#pixelValue");
const processingValue = document.querySelector("#processingValue");
const outputValue = document.querySelector("#outputValue");
const videoControls = document.querySelectorAll(".requires-video");
const supportsVideoFrameCallback =
"requestVideoFrameCallback" in HTMLVideoElement.prototype;
let sourceUrl = null;
let downloadUrl = null;
let trimStart = 0;
let trimEnd = 0;
let showOriginal = false;
let renderLoopActive = false;
let renderedFrames = 0;
let exporting = false;
let activeRecorder = null;
let activeStream = null;
let previousMutedState = false;
function setVideoControlsDisabled(disabled) {
videoControls.forEach((control) => {
control.disabled = disabled;
});
}
function formatTime(seconds) {
if (!Number.isFinite(seconds)) {
return "0:00.00";
}
const minutes = Math.floor(seconds / 60);
const remaining = (seconds % 60).toFixed(2).padStart(5, "0");
return `${minutes}:${remaining}`;
}
function formatBytes(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
const kilobytes = bytes / 1024;
if (kilobytes < 1024) {
return `${kilobytes.toFixed(1)} KB`;
}
return `${(kilobytes / 1024).toFixed(2)} MB`;
}
function sliderToTime(value) {
return (Number(value) / TIMELINE_MAX) * video.duration;
}
function timeToSlider(time) {
return Math.round((time / video.duration) * TIMELINE_MAX);
}
function updateTimelineReadouts() {
const current = Number.isFinite(video.currentTime) ? video.currentTime : 0;
const duration = Number.isFinite(video.duration) ? video.duration : 0;
timeReadout.textContent = `${formatTime(current)} / ${formatTime(duration)}`;
trimReadout.textContent =
`Selected range: ${formatTime(trimStart)} to ${formatTime(trimEnd)}`;
if (duration > 0 && !playheadInput.matches(":active")) {
playheadInput.value = String(timeToSlider(current));
}
}
function clampChannel(value) {
return Math.max(0, Math.min(255, Math.round(value)));
}
function drawCover() {
const scale = Math.max(
canvas.width / video.videoWidth,
canvas.height / video.videoHeight,
);
const sourceWidth = canvas.width / scale;
const sourceHeight = canvas.height / scale;
const sourceX = (video.videoWidth - sourceWidth) / 2;
const sourceY = (video.videoHeight - sourceHeight) / 2;
ctx.drawImage(
video,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
canvas.width,
canvas.height,
);
}
function applyPixelEffects() {
const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = frame.data;
const brightness = Number(brightnessInput.value);
const saturation = Number(saturationInput.value) / 100;
for (let index = 0; index < pixels.length; index += 4) {
const red = pixels[index];
const green = pixels[index + 1];
const blue = pixels[index + 2];
const gray = 0.299 * red + 0.587 * green + 0.114 * blue;
pixels[index] = clampChannel(
gray + (red - gray) * saturation + brightness,
);
pixels[index + 1] = clampChannel(
gray + (green - gray) * saturation + brightness,
);
pixels[index + 2] = clampChannel(
gray + (blue - gray) * saturation + brightness,
);
}
const centerX = Math.floor(canvas.width / 2);
const centerY = Math.floor(canvas.height / 2);
const centerIndex = (centerY * canvas.width + centerX) * 4;
const centerPixel = [
pixels[centerIndex],
pixels[centerIndex + 1],
pixels[centerIndex + 2],
pixels[centerIndex + 3],
];
ctx.putImageData(frame, 0, 0);
return centerPixel;
}
function readCenterPixel() {
const centerX = Math.floor(canvas.width / 2);
const centerY = Math.floor(canvas.height / 2);
return Array.from(ctx.getImageData(centerX, centerY, 1, 1).data);
}
function updateInspector(metadata, centerPixel, elapsed) {
const mediaTime = metadata?.mediaTime ?? video.currentTime;
const presentedFrame = metadata?.presentedFrames ?? renderedFrames;
sourceDimensions.textContent = `${video.videoWidth} × ${video.videoHeight}`;
callbackMode.textContent = supportsVideoFrameCallback
? "requestVideoFrameCallback"
: "requestAnimationFrame fallback";
mediaTimeValue.textContent = `${mediaTime.toFixed(3)} s`;
frameValue.textContent = String(presentedFrame);
pixelValue.textContent =
`rgba(${centerPixel[0]}, ${centerPixel[1]}, ${centerPixel[2]}, ${centerPixel[3]})`;
processingValue.textContent = `${elapsed.toFixed(2)} ms`;
}
function drawProcessedFrame(metadata = null) {
if (video.readyState < 2 || video.videoWidth === 0) {
return;
}
const startedAt = performance.now();
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawCover();
let centerPixel;
if (showOriginal) {
centerPixel = readCenterPixel();
} else {
centerPixel = applyPixelEffects();
}
renderedFrames += 1;
const elapsed = performance.now() - startedAt;
updateInspector(metadata, centerPixel, elapsed);
emptyState.hidden = true;
}
function scheduleNextFrame() {
if (!renderLoopActive) {
return;
}
if (supportsVideoFrameCallback) {
video.requestVideoFrameCallback(renderVideoFrame);
} else {
requestAnimationFrame(renderAnimationFrame);
}
}
function renderVideoFrame(now, metadata) {
if (!renderLoopActive) {
return;
}
drawProcessedFrame(metadata);
if (video.paused || video.ended) {
renderLoopActive = false;
return;
}
scheduleNextFrame();
}
function renderAnimationFrame() {
if (!renderLoopActive) {
return;
}
drawProcessedFrame({
mediaTime: video.currentTime,
presentedFrames: renderedFrames,
width: video.videoWidth,
height: video.videoHeight,
});
if (video.paused || video.ended) {
renderLoopActive = false;
return;
}
scheduleNextFrame();
}
function startRenderLoop() {
if (renderLoopActive) {
return;
}
renderLoopActive = true;
scheduleNextFrame();
}
function seekTo(time) {
const clampedTime = Math.max(0, Math.min(video.duration, time));
if (Math.abs(video.currentTime - clampedTime) < 0.001) {
return Promise.resolve();
}
return new Promise((resolve) => {
video.addEventListener("seeked", resolve, { once: true });
video.currentTime = clampedTime;
});
}
async function togglePlayback() {
if (video.paused) {
if (video.currentTime < trimStart || video.currentTime >= trimEnd - 0.02) {
await seekTo(trimStart);
}
try {
await video.play();
} catch (error) {
statusMessage.textContent = `Playback could not start: ${error.message}`;
}
} else {
video.pause();
}
}
function updateEffectLabels() {
brightnessValue.textContent = brightnessInput.value;
saturationValue.textContent = `${saturationInput.value}%`;
}
function redrawPausedFrame() {
if (video.paused) {
drawProcessedFrame();
}
}
function resetDownload() {
if (downloadUrl) {
URL.revokeObjectURL(downloadUrl);
downloadUrl = null;
}
downloadLink.hidden = true;
downloadLink.removeAttribute("download");
downloadLink.href = "#";
outputValue.textContent = "Not exported";
}
fileInput.addEventListener("change", () => {
const file = fileInput.files[0];
if (!file) {
return;
}
if (sourceUrl) {
URL.revokeObjectURL(sourceUrl);
}
resetDownload();
video.pause();
sourceUrl = URL.createObjectURL(file);
video.src = sourceUrl;
video.load();
statusMessage.textContent = `Loading ${file.name}...`;
emptyState.hidden = false;
});
video.addEventListener("loadedmetadata", () => {
trimStart = 0;
trimEnd = video.duration;
trimStartInput.value = "0";
trimEndInput.value = String(TIMELINE_MAX);
playheadInput.value = "0";
renderedFrames = 0;
setVideoControlsDisabled(false);
sourceDimensions.textContent = `${video.videoWidth} × ${video.videoHeight}`;
callbackMode.textContent = supportsVideoFrameCallback
? "requestVideoFrameCallback"
: "requestAnimationFrame fallback";
statusMessage.textContent = "Clip loaded. Adjust the preview or press Play.";
updateTimelineReadouts();
});
video.addEventListener("loadeddata", () => {
drawProcessedFrame();
});
video.addEventListener("seeked", () => {
drawProcessedFrame();
updateTimelineReadouts();
});
video.addEventListener("play", () => {
playButton.textContent = "Pause";
startRenderLoop();
});
video.addEventListener("pause", () => {
playButton.textContent = "Play";
updateTimelineReadouts();
});
video.addEventListener("timeupdate", () => {
updateTimelineReadouts();
});
video.addEventListener("ended", () => {
video.currentTime = trimStart;
});
video.addEventListener("error", () => {
setVideoControlsDisabled(true);
statusMessage.textContent = "Microsoft Edge could not decode this video file.";
});
playButton.addEventListener("click", togglePlayback);
playheadInput.addEventListener("input", () => {
video.pause();
const target = Math.max(
trimStart,
Math.min(trimEnd, sliderToTime(playheadInput.value)),
);
playheadInput.value = String(timeToSlider(target));
video.currentTime = Math.min(target, video.duration - 0.001);
});
brightnessInput.addEventListener("input", () => {
updateEffectLabels();
redrawPausedFrame();
});
saturationInput.addEventListener("input", () => {
updateEffectLabels();
redrawPausedFrame();
});
compareButton.addEventListener("pointerdown", () => {
showOriginal = true;
drawProcessedFrame();
});
["pointerup", "pointercancel", "pointerleave"].forEach((eventName) => {
compareButton.addEventListener(eventName, () => {
showOriginal = false;
drawProcessedFrame();
});
});
window.addEventListener("beforeunload", () => {
if (sourceUrl) {
URL.revokeObjectURL(sourceUrl);
}
if (downloadUrl) {
URL.revokeObjectURL(downloadUrl);
}
if (activeStream) {
activeStream.getTracks().forEach((track) => track.stop());
}
});
setVideoControlsDisabled(true);
updateEffectLabels();
updateTimelineReadouts();
Before you run the final check, which inspector values should change continuously while the clip plays?
- Save app.js.
- Refresh FrameLab in Microsoft Edge.
- Select your local clip.
- Select Play.
- Move the Brightness slider.
- Move the Saturation slider.
- Hold Hold to Compare Original.
You will see correct source proportions plus visible color changes. The center-pixel RGBA value plus processing time will update as frames render.
That is the pixel pipeline working. FrameLab can now show exactly how geometry plus channel math transform each frame.
Does the Inspector Stop Updating?
Confirm that updateInspector(metadata, centerPixel, elapsed) remains inside drawProcessedFrame.
Check both branches for a centerPixel value. The processed path returns it from applyPixelEffects().
Help me debug the FrameLab inspector.
Your preview now preserves the source geometry plus exposes its pixel math. Next, you will turn this processor into an editor with trim boundaries plus composited text.
Add Trimming, Text, and Inspection
Your Canvas 2D rendering pipeline now preserves the clip’s proportions. It also exposes every output pixel for brightness plus saturation processing.
A usable editor also needs a selected story range. This step adds normalized trimming plus a composited caption while keeping the media pipeline visible through the inspector.
In this step, get ready to:
- Convert normalized slider positions into media time.
- Keep playback inside a positive trim range.
- Composite caption text while monitoring the live media inspector.
Map the timeline to media time
A normalized timeline represents the full clip with values from 0 to 1000. FrameLab converts those positions into seconds using the loaded video’s duration.
- Switch back to Visual Studio Code through the app from earlier.
- Select app.js in the Explorer sidebar.
- Find the redrawPausedFrame() function.
- Add these trim helpers below redrawPausedFrame():
function minimumTrimGap() {
return Math.min(0.25, video.duration / 4);
}
function updateTrimStart() {
video.pause();
const requested = sliderToTime(trimStartInput.value);
trimStart = Math.min(requested, trimEnd - minimumTrimGap());
trimStart = Math.max(0, trimStart);
trimStartInput.value = String(timeToSlider(trimStart));
video.currentTime = trimStart;
updateTimelineReadouts();
}
function updateTrimEnd() {
video.pause();
const requested = sliderToTime(trimEndInput.value);
trimEnd = Math.max(requested, trimStart + minimumTrimGap());
trimEnd = Math.min(video.duration, trimEnd);
trimEndInput.value = String(timeToSlider(trimEnd));
video.currentTime = Math.max(trimStart, trimEnd - 0.001);
updateTimelineReadouts();
}
How Does Trim Clamping Work?
- The minimumTrimGap() helper preserves a positive interval between the Start marker plus the End marker.
- The updateTrimStart() function prevents Start from crossing End. It seeks the video to the accepted Start time.
- The updateTrimEnd() function prevents End from crossing Start. It seeks to the final visible moment inside the range.
- Both functions convert the accepted media time back into a normalized slider value. The controls therefore show the value FrameLab actually accepted.
- Find the existing playButton click listener near the bottom of app.js.
- Add these playhead plus trim listeners directly below it:
playheadInput.addEventListener("input", () => {
video.pause();
const target = Math.max(
trimStart,
Math.min(trimEnd, sliderToTime(playheadInput.value)),
);
playheadInput.value = String(timeToSlider(target));
video.currentTime = Math.min(target, video.duration - 0.001);
});
trimStartInput.addEventListener("input", updateTrimStart);
trimEndInput.addEventListener("input", updateTrimEnd);
What Do These Listeners Control?
- The playhead listener pauses playback before seeking. It clamps the requested time to the selected trim range.
- The trim listeners run their matching boundary functions whenever you move either marker.
- The existing seek event redraws the selected frame. This makes each timeline adjustment immediately visible.
- Save app.js.
- Switch back to Microsoft Edge through the browser from earlier.
- Refresh the FrameLab page.
- Choose your local video again from the Choose local video field.
- Move the Playhead slider to the middle of the selected range.
You’ll see the canvas jump to the matching point in the clip. The media-time value in the inspector updates with the selected frame.
- Move the Start slider forward.
- Move the End slider backward.
- Try to drag either marker across the other marker.
The Selected range readout follows both markers. FrameLab preserves a small positive gap when the sliders approach each other.
Do the Sliders Ignore Your Trim Range?
Confirm that both trim listeners appear below the playheadInput listener. Check that each listener names its matching input plus update function.
Look for the normalized conversion helpers from the existing file. The trim functions depend on sliderToTime() plus timeToSlider().
Help me debug FrameLab’s normalized trim sliders.
Keep playback inside the trim
The sliders now define a range. Playback still needs a boundary check that returns ordinary viewing to the Start marker when it reaches End.
- Return to app.js in the Explorer sidebar.
- Find the closing brace for drawProcessedFrame().
- Add this boundary function directly below drawProcessedFrame():
function enforceTrimBoundary() {
if (!Number.isFinite(video.duration)) {
return;
}
if (video.currentTime >= trimEnd - 0.02) {
if (exporting) {
finishExport();
} else {
video.pause();
video.currentTime = trimStart;
}
}
}
What Does the Boundary Check Do?
The function waits until the current media time reaches the End marker. Normal playback pauses before seeking back to the selected Start marker.
The small time tolerance catches a frame close to End. This avoids depending on the video callback landing on one exact fractional second.
- Find the existing renderVideoFrame() function.
- Replace that function with this bounded version:
function renderVideoFrame(now, metadata) {
if (!renderLoopActive) {
return;
}
drawProcessedFrame(metadata);
enforceTrimBoundary();
if (video.paused || video.ended) {
renderLoopActive = false;
return;
}
scheduleNextFrame();
}
Why Check After Drawing?
FrameLab draws the current video frame before checking the selected End marker. The final processed frame remains visible when playback returns to Start.
The callback then stops scheduling work once the video pauses. This keeps the render loop tied to active playback.
- Save app.js.
- Refresh FrameLab in Microsoft Edge.
- Choose your local video again.
- Set the Start marker near the beginning.
- Set the End marker a few seconds later.
- Select Play.
You’ll see playback stop at the End marker. The playhead returns to the selected Start marker.
- Return to app.js.
- Find the existing renderAnimationFrame() fallback.
- Replace that function with this bounded version:
function renderAnimationFrame() {
if (!renderLoopActive) {
return;
}
drawProcessedFrame({
mediaTime: video.currentTime,
presentedFrames: renderedFrames,
width: video.videoWidth,
height: video.videoHeight,
});
enforceTrimBoundary();
if (video.paused || video.ended) {
renderLoopActive = false;
return;
}
scheduleNextFrame();
}
Why Update the Fallback Too?
The fallback produces the same bounded playback behavior when video-frame callbacks are unavailable. It also supplies the metadata shape used by the inspector.
Both callback paths now draw the frame before enforcing the selected End marker.
- Find the existing timeupdate listener near the bottom of app.js.
- Replace that listener with this additional boundary check:
video.addEventListener("timeupdate", () => {
updateTimelineReadouts();
if (!video.paused) {
enforceTrimBoundary();
}
});
Why Use a Second Boundary Signal?
The render callbacks enforce the range while FrameLab paints frames. The media element’s own timeline update provides another signal during playback.
Both paths call the same helper. The trim behavior therefore has one source of truth.
- Find the existing togglePlayback() function.
- Replace that function with this trim-aware version:
async function togglePlayback() {
if (video.paused) {
if (video.currentTime < trimStart || video.currentTime >= trimEnd - 0.02) {
await seekTo(trimStart);
}
try {
await video.play();
} catch (error) {
statusMessage.textContent = `Playback could not start: ${error.message}`;
}
} else {
video.pause();
}
}
How Does Playback Re-enter the Range?
The function checks the current media time before starting playback. A playhead outside the selected interval is moved to trimStart first.
The existing seekTo() promise waits for the seek to finish. Playback therefore begins from the accepted boundary.
- Save app.js.
- Refresh FrameLab in Microsoft Edge.
- Choose your local video again.
- Set a short trim range.
- Drag the Playhead to the End marker.
Before you select Play, where do you expect playback to begin?
- Select Play.
Playback begins from the selected Start marker. It stops at End before returning to Start.
Does Playback Escape the Range?
Confirm that enforceTrimBoundary() appears after the draw call in both rendering functions. Check the timeupdate listener for the same function call.
If Play starts outside the range, compare your togglePlayback() conditions with trimStart plus trimEnd.
Help me debug FrameLab’s trim-bounded playback.
Composite text and verify the inspector
Caption compositing draws text directly onto the processed canvas. Drawing it after the pixel transform keeps the lettering crisp while making it part of the canvas output.
- Return to app.js in Visual Studio Code.
- Find the closing brace for readCenterPixel().
- Add this caption function directly below readCenterPixel():
function drawTextOverlay() {
const text = overlayTextInput.value.trim();
if (!text) {
return;
}
ctx.save();
ctx.font = "700 32px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.lineJoin = "round";
ctx.lineWidth = 7;
ctx.strokeStyle = "rgb(0 0 0 / 0.78)";
ctx.fillStyle = "#ffffff";
const x = canvas.width / 2;
const y = canvas.height * 0.82;
const maxWidth = canvas.width * 0.86;
ctx.strokeText(text, x, y, maxWidth);
ctx.fillText(text, x, y, maxWidth);
ctx.restore();
}
How Is the Caption Drawn?
- The trimmed input value allows an empty caption to skip the drawing work.
- The saved canvas state contains the temporary font plus alignment settings. Restoring the state prevents those settings from leaking into later drawing work.
- The dark text stroke separates the letters from bright footage. The white fill creates the visible caption.
- The lower-canvas coordinates place the caption near the bottom of the portrait frame.
- Find the existing drawProcessedFrame() function.
- Replace that function with this compositing version:
function drawProcessedFrame(metadata = null) {
if (video.readyState < 2 || video.videoWidth === 0) {
return;
}
const startedAt = performance.now();
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawCover();
let centerPixel;
if (showOriginal) {
centerPixel = readCenterPixel();
} else {
centerPixel = applyPixelEffects();
drawTextOverlay();
}
renderedFrames += 1;
const elapsed = performance.now() - startedAt;
updateInspector(metadata, centerPixel, elapsed);
emptyState.hidden = true;
}
Where Does Compositing Happen?
FrameLab first draws the center-cropped source. It then applies the RGBA transform before drawing the caption.
Holding Compare Original bypasses both the pixel effects plus the caption. The comparison therefore reveals the untouched source frame.
- Save app.js.
- Refresh FrameLab in Microsoft Edge.
- Choose your local video again.
- Select Play.
You’ll see Built frame by frame near the bottom of the moving preview. The lettering remains crisp over the processed pixels.
- Return to app.js.
- Find the existing updateEffectLabels() function.
- Add this paused-frame helper directly below it:
function redrawPausedFrame() {
if (video.paused) {
drawProcessedFrame();
}
}
Why Redraw While Paused?
A paused video does not produce new playback frames. This helper explicitly redraws the current frame when an editing control changes.
The same helper supports brightness plus saturation updates. Caption edits now follow the same immediate-feedback path.
- Find the existing brightness plus saturation listeners near the bottom of app.js.
- Add this caption listener directly below the saturation listener:
overlayTextInput.addEventListener("input", redrawPausedFrame);
What Does the Caption Listener Change?
Every text input change calls redrawPausedFrame(). A paused preview therefore shows the edited caption without requiring playback.
- Save app.js.
- Refresh FrameLab in Microsoft Edge.
- Choose your local video again.
- Pause the clip on a clear frame.
- Replace the Overlay text value with FrameLab test.
You’ll see FrameLab test appear immediately on the paused canvas. Playback is not required for the caption redraw.
✔️ Awesome, I've got everything!
Great. Your saved app.js now includes normalized scrubbing, bounded trimming, caption compositing plus paused-frame redraws.
ⓧ I'd like to double check the full code
Compare the completed sections in app.js with this checklist.
- Confirm minimumTrimGap() appears immediately before updateTrimStart() plus updateTrimEnd().
- Confirm the playhead listener clamps its target between trimStart plus trimEnd.
- Confirm enforceTrimBoundary() appears after drawProcessedFrame().
- Confirm both rendering callbacks call enforceTrimBoundary() after drawing a processed frame.
- Confirm togglePlayback() seeks to trimStart when the current time sits outside the selected range.
- Confirm drawTextOverlay() calls strokeText() before fillText().
- Confirm drawProcessedFrame() calls drawTextOverlay() after applyPixelEffects().
- Confirm the overlayTextInput listener calls redrawPausedFrame().
Before the final check, which inspector values do you expect to change during playback?
- Set different positions for the Start plus End markers.
- Enter a short caption in the Overlay text field.
- Move the Brightness slider away from 0.
- Move the Saturation slider away from 100%.
- Select Play.
- Hold Hold to Compare Original over a visible frame.
You’ll see playback remain inside the selected range. The processed preview shows the caption plus the adjusted colors until the comparison control reveals the original frame.
The Media Inspector continuously updates the source dimensions, callback mode, media time, frame count, center-pixel RGBA value plus processing duration. That live evidence makes every stage of the editor inspectable.
Is the Caption or Inspector Frozen?
Confirm that drawTextOverlay() runs inside the processed branch of drawProcessedFrame(). Check that the caption input listener calls redrawPausedFrame().
If inspector values stop during playback, check that both rendering callbacks still pass metadata into drawProcessedFrame().
Help me debug FrameLab’s caption or live inspector.
That’s the editing loop complete: FrameLab now selects a story range, composites a caption plus reports the work behind each frame. Next, you’ll turn that live canvas into a downloadable video.
Export the Processed Segment
Your trim controls now keep playback inside the selected story range. Your caption also appears in every processed Canvas frame.
Canvas frames disappear when playback stops. This step records those live frames into a persistent video file that you can replay outside FrameLab.
In this step, get ready to:
- Capture the processed canvas as a video stream.
- Record only the selected trim range in real time.
- Create a downloadable file with output metadata.
Prepare the recording lifecycle
A live export needs shared state for the recorder stream. It also needs object URLs that remain available until FrameLab replaces them or closes.
- Switch back to app.js in the Visual Studio Code Explorer sidebar.
- Find the mutable state block that starts with let sourceUrl = null;.
- Replace that state block with this expanded version:
let sourceUrl = null;
let downloadUrl = null;
let trimStart = 0;
let trimEnd = 0;
let showOriginal = false;
let renderLoopActive = false;
let renderedFrames = 0;
let exporting = false;
let activeRecorder = null;
let activeStream = null;
let previousMutedState = false;
What Does This State Track?
- The exporting flag tells the trim boundary whether it should finish a recording or reset normal playback.
- The recorder plus stream references let FrameLab stop active media work during export cleanup.
- The URL plus muted-state values let FrameLab replace old downloads and restore normal playback after recording.
- Find the closing brace of formatTime().
- Add the byte-formatting helper directly below it:
function formatBytes(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
const kilobytes = bytes / 1024;
if (kilobytes < 1024) {
return `${kilobytes.toFixed(1)} KB`;
}
return `${(kilobytes / 1024).toFixed(2)} MB`;
}
What Does This Helper Do?
- Small exports stay in bytes.
- Larger exports become readable kilobyte or megabyte values for the Media Inspector.
- Save app.js.
- Return to Microsoft Edge from earlier.
- Refresh FrameLab.
- Select the same local clip through Choose local video.
FrameLab loads the clip again. The preview plus editing controls still work without console errors.
Each export creates a new temporary download URL. The next helper revokes the old URL before preparing a replacement.
- Find updateTrimEnd() in app.js.
- Add this download cleanup helper directly below it:
function resetDownload() {
if (downloadUrl) {
URL.revokeObjectURL(downloadUrl);
downloadUrl = null;
}
downloadLink.hidden = true;
downloadLink.removeAttribute("download");
downloadLink.href = "#";
outputValue.textContent = "Not exported";
}
How Does Download Cleanup Work?
- The old object URL is revoked before FrameLab stores another recording.
- The download link returns to its hidden pre-export state.
- The inspector returns its export field to Not exported.
- Add the recording stop helper directly below resetDownload():
function finishExport() {
if (!exporting) {
return;
}
exporting = false;
video.pause();
if (activeRecorder && activeRecorder.state !== "inactive") {
statusMessage.textContent = "Finalizing the recorded chunks...";
activeRecorder.stop();
}
}
How Does Recording Stop?
- The guard prevents repeated stop requests after the trim boundary has already finished the export.
- Pausing the source freezes the processed canvas at the End marker.
- Stopping the active recorder triggers its final recorded-data event.
- Save app.js.
- Refresh FrameLab in Microsoft Edge.
- Select the same local clip again.
The clip loads with its preview controls enabled. FrameLab now has the state plus cleanup helpers needed by the recorder.
Record the selected canvas frames
The canvas provides a live MediaStream containing its changing video frames. A MediaRecorder consumes that stream while the source clip plays from Start to End.
Why MediaRecorder for This Export?
MediaRecorder accepts the live canvas stream without requiring a forced codec. The browser can select a recording type that it supports.
This keeps the export focused on trim timing plus processed frames. The canvas stream contains video only.
- Add this first version of exportClip() directly below finishExport():
async function exportClip() {
video.pause();
await seekTo(trimStart);
drawProcessedFrame();
activeStream = canvas.captureStream();
const recorder = new MediaRecorder(activeStream);
activeRecorder = recorder;
previousMutedState = video.muted;
video.muted = true;
fileInput.disabled = true;
exportButton.disabled = true;
exportButton.textContent = "Exporting...";
statusMessage.textContent = "Exporting in real time until the End marker...";
exporting = true;
recorder.start(250);
try {
await video.play();
} catch (error) {
statusMessage.textContent = `Export playback failed: ${error.message}`;
finishExport();
}
}
What Does the Recording Pass Do?
- The function seeks to trimStart before capturing the first processed frame.
- The canvas stream becomes the recorder input.
- Source audio is muted during real-time playback because the output contains canvas video only.
- The existing trim boundary calls finishExport() when playback reaches trimEnd.
- Find the existing comparison event listeners near the bottom of app.js.
- Add this export listener directly below them:
exportButton.addEventListener("click", exportClip);
How Does the Button Start Exporting?
Selecting Export Trimmed Clip now calls exportClip(). The same trim values used by normal playback control the recording range.
- Save app.js.
- Refresh FrameLab in Microsoft Edge.
- Select the same local clip.
- Move the Start marker away from the beginning.
- Move the End marker closer to the Start marker.
Before you select Export Trimmed Clip, do you expect recording to begin from the current playhead or from the Start marker?
- Select Export Trimmed Clip.
Playback begins at the Start marker. The status changes to Exporting in real time until the End marker....
At the End marker the source pauses. The status changes to Finalizing the recorded chunks....
Good progress. The selected range now drives a real canvas recording from its first processed frame to its last.
Create the downloadable video
Recorded data arrives in chunks before the recorder stops. FrameLab combines those chunks into a Blob and exposes it through a temporary Blob URL.
- Return to exportClip() in app.js.
- Find the line that assigns activeRecorder.
- Add the chunk collection directly below that line:
const chunks = [];
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
});
How Are Recorded Chunks Collected?
- The dataavailable listener receives recorded media data.
- Empty events are ignored.
- Every populated chunk remains available until the recorder finishes.
- Add this stop handler directly below the chunk listener:
recorder.addEventListener("stop", () => {
activeStream.getTracks().forEach((track) => track.stop());
const outputType = recorder.mimeType || chunks[0]?.type || "video/webm";
const blob = new Blob(chunks, { type: outputType });
downloadUrl = URL.createObjectURL(blob);
const extension = outputType.includes("mp4") ? "mp4" : "webm";
downloadLink.href = downloadUrl;
downloadLink.download = `framelab-export.${extension}`;
downloadLink.textContent = `Download framelab-export.${extension}`;
downloadLink.hidden = false;
outputValue.textContent = `${outputType} | ${formatBytes(blob.size)}`;
statusMessage.textContent = "Export complete. Download the processed clip.";
video.muted = previousMutedState;
fileInput.disabled = false;
exportButton.disabled = false;
exportButton.textContent = "Export Trimmed Clip";
activeRecorder = null;
activeStream = null;
drawProcessedFrame();
});
How Does the File Become Downloadable?
- Every capture track stops before FrameLab assembles the output.
- The recorder-selected MIME type becomes the Blob type and determines the file extension.
- The Blob URL becomes the download destination.
- The Media Inspector reports the selected MIME type plus the formatted file size.
- Playback controls return to their normal state after the export.
- Save app.js.
- Refresh FrameLab in Microsoft Edge.
- Select the same local clip.
- Choose a short trim range.
- Adjust the brightness slider.
- Adjust the saturation slider.
- Enter a caption in Overlay text.
Before you export, which visible details do you expect the downloaded recording to preserve?
A real-time export can feel slow. A ten-second selection takes about ten seconds to record.
- Select Export Trimmed Clip.
Playback stops at the End marker. You will see a download link plus a MIME type and file size in the inspector.
That is the core export working. FrameLab has turned the selected processed frames into a persistent browser recording.
Two final guards make repeated exports safer. They prevent overlapping recordings and release temporary resources when the page closes.
- Find the opening lines of exportClip().
- Replace the opening through the first call to drawProcessedFrame() with this version:
async function exportClip() {
if (exporting || video.readyState < 2) {
return;
}
if (!("MediaRecorder" in window) || !("captureStream" in canvas)) {
statusMessage.textContent = "This browser cannot record the canvas stream.";
return;
}
resetDownload();
video.pause();
await seekTo(trimStart);
drawProcessedFrame();
What Do the Export Guards Check?
- An active export or an unready video causes the function to return.
- Missing recording support produces a clear status message.
- A valid export clears the previous download before seeking to the Start marker.
- Find the existing beforeunload listener near the bottom of app.js.
- Replace the complete listener with this final cleanup version:
window.addEventListener("beforeunload", () => {
if (sourceUrl) {
URL.revokeObjectURL(sourceUrl);
}
if (downloadUrl) {
URL.revokeObjectURL(downloadUrl);
}
if (activeStream) {
activeStream.getTracks().forEach((track) => track.stop());
}
});
What Does Page Cleanup Release?
- The source object URL is revoked when FrameLab closes.
- The generated download URL is revoked at the same point.
- Any remaining canvas capture tracks are stopped.
- Save app.js.
✔️ Awesome, I've got everything!
Your saved app.js now contains the complete recording and download pipeline.
ⓧ I'd like to double check the full code
Compare your complete app.js with this reference. Keep the functions plus event listeners in the same order.
const TIMELINE_MAX = 1000;
const fileInput = document.querySelector("#videoFile");
const video = document.querySelector("#sourceVideo");
const canvas = document.querySelector("#previewCanvas");
const ctx = canvas.getContext("2d");
const emptyState = document.querySelector("#emptyState");
const playButton = document.querySelector("#playButton");
const playheadInput = document.querySelector("#playhead");
const trimStartInput = document.querySelector("#trimStart");
const trimEndInput = document.querySelector("#trimEnd");
const timeReadout = document.querySelector("#timeReadout");
const trimReadout = document.querySelector("#trimReadout");
const brightnessInput = document.querySelector("#brightness");
const saturationInput = document.querySelector("#saturation");
const brightnessValue = document.querySelector("#brightnessValue");
const saturationValue = document.querySelector("#saturationValue");
const compareButton = document.querySelector("#compareButton");
const overlayTextInput = document.querySelector("#overlayText");
const exportButton = document.querySelector("#exportButton");
const downloadLink = document.querySelector("#downloadLink");
const statusMessage = document.querySelector("#statusMessage");
const sourceDimensions = document.querySelector("#sourceDimensions");
const callbackMode = document.querySelector("#callbackMode");
const mediaTimeValue = document.querySelector("#mediaTimeValue");
const frameValue = document.querySelector("#frameValue");
const pixelValue = document.querySelector("#pixelValue");
const processingValue = document.querySelector("#processingValue");
const outputValue = document.querySelector("#outputValue");
const videoControls = document.querySelectorAll(".requires-video");
const supportsVideoFrameCallback =
"requestVideoFrameCallback" in HTMLVideoElement.prototype;
let sourceUrl = null;
let downloadUrl = null;
let trimStart = 0;
let trimEnd = 0;
let showOriginal = false;
let renderLoopActive = false;
let renderedFrames = 0;
let exporting = false;
let activeRecorder = null;
let activeStream = null;
let previousMutedState = false;
function setVideoControlsDisabled(disabled) {
videoControls.forEach((control) => {
control.disabled = disabled;
});
}
function formatTime(seconds) {
if (!Number.isFinite(seconds)) {
return "0:00.00";
}
const minutes = Math.floor(seconds / 60);
const remaining = (seconds % 60).toFixed(2).padStart(5, "0");
return `${minutes}:${remaining}`;
}
function formatBytes(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
const kilobytes = bytes / 1024;
if (kilobytes < 1024) {
return `${kilobytes.toFixed(1)} KB`;
}
return `${(kilobytes / 1024).toFixed(2)} MB`;
}
function sliderToTime(value) {
return (Number(value) / TIMELINE_MAX) * video.duration;
}
function timeToSlider(time) {
return Math.round((time / video.duration) * TIMELINE_MAX);
}
function updateTimelineReadouts() {
const current = Number.isFinite(video.currentTime) ? video.currentTime : 0;
const duration = Number.isFinite(video.duration) ? video.duration : 0;
timeReadout.textContent = `${formatTime(current)} / ${formatTime(duration)}`;
trimReadout.textContent =
`Selected range: ${formatTime(trimStart)} to ${formatTime(trimEnd)}`;
if (duration > 0 && !playheadInput.matches(":active")) {
playheadInput.value = String(timeToSlider(current));
}
}
function clampChannel(value) {
return Math.max(0, Math.min(255, Math.round(value)));
}
function drawCover() {
const scale = Math.max(
canvas.width / video.videoWidth,
canvas.height / video.videoHeight,
);
const sourceWidth = canvas.width / scale;
const sourceHeight = canvas.height / scale;
const sourceX = (video.videoWidth - sourceWidth) / 2;
const sourceY = (video.videoHeight - sourceHeight) / 2;
ctx.drawImage(
video,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
canvas.width,
canvas.height,
);
}
function applyPixelEffects() {
const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = frame.data;
const brightness = Number(brightnessInput.value);
const saturation = Number(saturationInput.value) / 100;
for (let index = 0; index < pixels.length; index += 4) {
const red = pixels[index];
const green = pixels[index + 1];
const blue = pixels[index + 2];
const gray = 0.299 * red + 0.587 * green + 0.114 * blue;
pixels[index] = clampChannel(
gray + (red - gray) * saturation + brightness,
);
pixels[index + 1] = clampChannel(
gray + (green - gray) * saturation + brightness,
);
pixels[index + 2] = clampChannel(
gray + (blue - gray) * saturation + brightness,
);
}
const centerX = Math.floor(canvas.width / 2);
const centerY = Math.floor(canvas.height / 2);
const centerIndex = (centerY * canvas.width + centerX) * 4;
const centerPixel = [
pixels[centerIndex],
pixels[centerIndex + 1],
pixels[centerIndex + 2],
pixels[centerIndex + 3],
];
ctx.putImageData(frame, 0, 0);
return centerPixel;
}
function readCenterPixel() {
const centerX = Math.floor(canvas.width / 2);
const centerY = Math.floor(canvas.height / 2);
return Array.from(ctx.getImageData(centerX, centerY, 1, 1).data);
}
function drawTextOverlay() {
const text = overlayTextInput.value.trim();
if (!text) {
return;
}
ctx.save();
ctx.font = "700 32px system-ui";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.lineJoin = "round";
ctx.lineWidth = 7;
ctx.strokeStyle = "rgb(0 0 0 / 0.78)";
ctx.fillStyle = "#ffffff";
const x = canvas.width / 2;
const y = canvas.height * 0.82;
const maxWidth = canvas.width * 0.86;
ctx.strokeText(text, x, y, maxWidth);
ctx.fillText(text, x, y, maxWidth);
ctx.restore();
}
function updateInspector(metadata, centerPixel, elapsed) {
const mediaTime = metadata?.mediaTime ?? video.currentTime;
const presentedFrame = metadata?.presentedFrames ?? renderedFrames;
sourceDimensions.textContent = `${video.videoWidth} × ${video.videoHeight}`;
callbackMode.textContent = supportsVideoFrameCallback
? "requestVideoFrameCallback"
: "requestAnimationFrame fallback";
mediaTimeValue.textContent = `${mediaTime.toFixed(3)} s`;
frameValue.textContent = String(presentedFrame);
pixelValue.textContent =
`rgba(${centerPixel[0]}, ${centerPixel[1]}, ${centerPixel[2]}, ${centerPixel[3]})`;
processingValue.textContent = `${elapsed.toFixed(2)} ms`;
}
function drawProcessedFrame(metadata = null) {
if (video.readyState < 2 || video.videoWidth === 0) {
return;
}
const startedAt = performance.now();
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawCover();
let centerPixel;
if (showOriginal) {
centerPixel = readCenterPixel();
} else {
centerPixel = applyPixelEffects();
drawTextOverlay();
}
renderedFrames += 1;
const elapsed = performance.now() - startedAt;
updateInspector(metadata, centerPixel, elapsed);
emptyState.hidden = true;
}
function enforceTrimBoundary() {
if (!Number.isFinite(video.duration)) {
return;
}
if (video.currentTime >= trimEnd - 0.02) {
if (exporting) {
finishExport();
} else {
video.pause();
video.currentTime = trimStart;
}
}
}
function scheduleNextFrame() {
if (!renderLoopActive) {
return;
}
if (supportsVideoFrameCallback) {
video.requestVideoFrameCallback(renderVideoFrame);
} else {
requestAnimationFrame(renderAnimationFrame);
}
}
function renderVideoFrame(now, metadata) {
if (!renderLoopActive) {
return;
}
drawProcessedFrame(metadata);
enforceTrimBoundary();
if (video.paused || video.ended) {
renderLoopActive = false;
return;
}
scheduleNextFrame();
}
function renderAnimationFrame() {
if (!renderLoopActive) {
return;
}
drawProcessedFrame({
mediaTime: video.currentTime,
presentedFrames: renderedFrames,
width: video.videoWidth,
height: video.videoHeight,
});
enforceTrimBoundary();
if (video.paused || video.ended) {
renderLoopActive = false;
return;
}
scheduleNextFrame();
}
function startRenderLoop() {
if (renderLoopActive) {
return;
}
renderLoopActive = true;
scheduleNextFrame();
}
function seekTo(time) {
const clampedTime = Math.max(0, Math.min(video.duration, time));
if (Math.abs(video.currentTime - clampedTime) < 0.001) {
return Promise.resolve();
}
return new Promise((resolve) => {
video.addEventListener("seeked", resolve, { once: true });
video.currentTime = clampedTime;
});
}
async function togglePlayback() {
if (video.paused) {
if (video.currentTime < trimStart || video.currentTime >= trimEnd - 0.02) {
await seekTo(trimStart);
}
try {
await video.play();
} catch (error) {
statusMessage.textContent = `Playback could not start: ${error.message}`;
}
} else {
video.pause();
}
}
function updateEffectLabels() {
brightnessValue.textContent = brightnessInput.value;
saturationValue.textContent = `${saturationInput.value}%`;
}
function redrawPausedFrame() {
if (video.paused) {
drawProcessedFrame();
}
}
function minimumTrimGap() {
return Math.min(0.25, video.duration / 4);
}
function updateTrimStart() {
video.pause();
const requested = sliderToTime(trimStartInput.value);
trimStart = Math.min(requested, trimEnd - minimumTrimGap());
trimStart = Math.max(0, trimStart);
trimStartInput.value = String(timeToSlider(trimStart));
video.currentTime = trimStart;
updateTimelineReadouts();
}
function updateTrimEnd() {
video.pause();
const requested = sliderToTime(trimEndInput.value);
trimEnd = Math.max(requested, trimStart + minimumTrimGap());
trimEnd = Math.min(video.duration, trimEnd);
trimEndInput.value = String(timeToSlider(trimEnd));
video.currentTime = Math.max(trimStart, trimEnd - 0.001);
updateTimelineReadouts();
}
function resetDownload() {
if (downloadUrl) {
URL.revokeObjectURL(downloadUrl);
downloadUrl = null;
}
downloadLink.hidden = true;
downloadLink.removeAttribute("download");
downloadLink.href = "#";
outputValue.textContent = "Not exported";
}
function finishExport() {
if (!exporting) {
return;
}
exporting = false;
video.pause();
if (activeRecorder && activeRecorder.state !== "inactive") {
statusMessage.textContent = "Finalizing the recorded chunks...";
activeRecorder.stop();
}
}
async function exportClip() {
if (exporting || video.readyState < 2) {
return;
}
if (!("MediaRecorder" in window) || !("captureStream" in canvas)) {
statusMessage.textContent = "This browser cannot record the canvas stream.";
return;
}
resetDownload();
video.pause();
await seekTo(trimStart);
drawProcessedFrame();
activeStream = canvas.captureStream();
const recorder = new MediaRecorder(activeStream);
activeRecorder = recorder;
const chunks = [];
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
});
recorder.addEventListener("stop", () => {
activeStream.getTracks().forEach((track) => track.stop());
const outputType = recorder.mimeType || chunks[0]?.type || "video/webm";
const blob = new Blob(chunks, { type: outputType });
downloadUrl = URL.createObjectURL(blob);
const extension = outputType.includes("mp4") ? "mp4" : "webm";
downloadLink.href = downloadUrl;
downloadLink.download = `framelab-export.${extension}`;
downloadLink.textContent = `Download framelab-export.${extension}`;
downloadLink.hidden = false;
outputValue.textContent = `${outputType} | ${formatBytes(blob.size)}`;
statusMessage.textContent = "Export complete. Download the processed clip.";
video.muted = previousMutedState;
fileInput.disabled = false;
exportButton.disabled = false;
exportButton.textContent = "Export Trimmed Clip";
activeRecorder = null;
activeStream = null;
drawProcessedFrame();
});
previousMutedState = video.muted;
video.muted = true;
fileInput.disabled = true;
exportButton.disabled = true;
exportButton.textContent = "Exporting...";
statusMessage.textContent = "Exporting in real time until the End marker...";
exporting = true;
recorder.start(250);
try {
await video.play();
} catch (error) {
statusMessage.textContent = `Export playback failed: ${error.message}`;
finishExport();
}
}
fileInput.addEventListener("change", () => {
const file = fileInput.files[0];
if (!file) {
return;
}
if (sourceUrl) {
URL.revokeObjectURL(sourceUrl);
}
resetDownload();
video.pause();
sourceUrl = URL.createObjectURL(file);
video.src = sourceUrl;
video.load();
statusMessage.textContent = `Loading ${file.name}...`;
emptyState.hidden = false;
});
video.addEventListener("loadedmetadata", () => {
trimStart = 0;
trimEnd = video.duration;
trimStartInput.value = "0";
trimEndInput.value = String(TIMELINE_MAX);
playheadInput.value = "0";
renderedFrames = 0;
setVideoControlsDisabled(false);
sourceDimensions.textContent = `${video.videoWidth} × ${video.videoHeight}`;
callbackMode.textContent = supportsVideoFrameCallback
? "requestVideoFrameCallback"
: "requestAnimationFrame fallback";
statusMessage.textContent = "Clip loaded. Adjust the preview or press Play.";
updateTimelineReadouts();
});
video.addEventListener("loadeddata", () => {
drawProcessedFrame();
});
video.addEventListener("seeked", () => {
drawProcessedFrame();
updateTimelineReadouts();
});
video.addEventListener("play", () => {
playButton.textContent = "Pause";
startRenderLoop();
});
video.addEventListener("pause", () => {
playButton.textContent = "Play";
updateTimelineReadouts();
});
video.addEventListener("timeupdate", () => {
updateTimelineReadouts();
if (!video.paused) {
enforceTrimBoundary();
}
});
video.addEventListener("ended", () => {
if (exporting) {
finishExport();
} else {
video.currentTime = trimStart;
}
});
video.addEventListener("error", () => {
setVideoControlsDisabled(true);
statusMessage.textContent = "Microsoft Edge could not decode this video file.";
});
playButton.addEventListener("click", togglePlayback);
playheadInput.addEventListener("input", () => {
video.pause();
const target = Math.max(
trimStart,
Math.min(trimEnd, sliderToTime(playheadInput.value)),
);
playheadInput.value = String(timeToSlider(target));
video.currentTime = Math.min(target, video.duration - 0.001);
});
trimStartInput.addEventListener("input", updateTrimStart);
trimEndInput.addEventListener("input", updateTrimEnd);
brightnessInput.addEventListener("input", () => {
updateEffectLabels();
redrawPausedFrame();
});
saturationInput.addEventListener("input", () => {
updateEffectLabels();
redrawPausedFrame();
});
overlayTextInput.addEventListener("input", redrawPausedFrame);
compareButton.addEventListener("pointerdown", () => {
showOriginal = true;
drawProcessedFrame();
});
["pointerup", "pointercancel", "pointerleave"].forEach((eventName) => {
compareButton.addEventListener(eventName, () => {
showOriginal = false;
drawProcessedFrame();
});
});
exportButton.addEventListener("click", exportClip);
window.addEventListener("beforeunload", () => {
if (sourceUrl) {
URL.revokeObjectURL(sourceUrl);
}
if (downloadUrl) {
URL.revokeObjectURL(downloadUrl);
}
if (activeStream) {
activeStream.getTracks().forEach((track) => track.stop());
}
});
setVideoControlsDisabled(true);
updateEffectLabels();
updateTimelineReadouts();
- Refresh FrameLab in Microsoft Edge.
- Select the same local clip.
- Set different Start and End positions.
- Adjust both effect sliders.
- Enter the caption you want to export.
Before you run the final export, do you expect the downloaded file to match the full source clip or only the selected processed range?
The recorder follows playback in real time. Keep FrameLab open until the status confirms completion.
- Select Export Trimmed Clip.
- Wait for playback to reach the End marker.
- Select the generated download link.
- Find the downloaded framelab-export.webm or framelab-export.mp4 in File Explorer.
- Drag the downloaded file onto a new tab in Microsoft Edge.
You will see the selected timing plus the center crop. You will also see the chosen effects plus caption.
The Media Inspector reports the browser-selected MIME type plus the Blob size. The exported file contains the canvas video track without source audio.
You made the live pixel pipeline persistent. FrameLab can now turn a local clip into a downloadable processed video.
Export Does Not Complete?
Use a short positive trim range. Confirm that the Start marker appears before the End marker.
Keep the FrameLab tab active while the selected range records. Reload the page if an earlier interrupted export left the controls disabled.
If Microsoft Edge cannot decode the source clip then select a different local video format.
Help me diagnose my FrameLab export.
Secret mission
Profile the Pixel Pipeline
Record filtered FrameLab playback with Microsoft Edge DevTools. Calculate the available frame budget, measure how much of it the pixel pipeline consumes, and choose an optimization target.
Clean Up Your Resources
Clean Up Your Resources
FrameLab runs entirely on your computer with no ongoing cost. Choose whether to keep the files, pause the browser session, or delete the local artifacts.
Resources you used:
- The framelab folder containing index.html, styles.css, and app.js.
- Any downloaded framelab-export.webm or framelab-export.mp4 file.
- A completed Microsoft Edge DevTools recording from the Performance panel with your frame-budget calculations and optimization target.
Keep everything running
No action needed. Choose this if you want to continue testing effects or inspecting frame performance.
- Keep the framelab folder available in Visual Studio Code.
- Keep any downloaded FrameLab export for playback or comparison.
- Leave the completed FrameLab tab open in Microsoft Edge.
- Leave the completed Performance recording open in DevTools.
Pause - I'll come back to this later
Close the browser session to release its temporary media resources. Your project files remain ready for another editing session.
- Close Microsoft Edge DevTools.
- Close the completed FrameLab tab in Microsoft Edge.
- Close Visual Studio Code if it is still showing the framelab folder.
Closing the FrameLab tab runs its unload cleanup, which revokes the temporary source and export URLs. Your project files and downloaded export remain on disk.
Delete - I don't want to use this again
Removing project files can feel final. Your original clip remains untouched because FrameLab never writes changes back to it.
- Close Microsoft Edge DevTools to discard the in-memory Performance recording.
- Close the completed FrameLab tab to run its unload cleanup.
- Close Visual Studio Code if it is still showing the framelab folder.
- Open the Windows file manager from system search.
- Locate the framelab folder in the parent location where you created it.
- Delete the framelab folder.
- Locate any framelab-export.webm or framelab-export.mp4 file in the location where Microsoft Edge saved it.
- Delete each FrameLab export that you no longer want.
Cleanup is complete. Your original clip remains available for another edit.
Nice Work!
Nice Work!
You made it! You built FrameLab as a local-first browser video editor in Microsoft Edge. You also measured its per-frame processing cost against a calculated frame budget.
You've learned how to:
- Diagnose a stretched Canvas 2D preview. Replace it with center-crop geometry that preserves the source proportions.
- Transform RGBA pixel data with explicit brightness formulas. Apply saturation math to the same pixels. Control the result with normalized trimming. Composite a caption into each processed frame. Inspect the pipeline through live frame metadata.
- Capture the processed canvas as a MediaStream. Record the selected range with MediaRecorder. Download the result as a video-only file with its MIME type and size reported in the inspector.
- Secret Mission: Profile the per-frame pixel loop in Microsoft Edge DevTools. Calculate frame budget = 1000 / FPS. Compare the measured processing time with that budget. Identify one optimization target.
Ready to quiz yourself?