User: You said
Moving on to next project, I want to build a video cropper in just html css js. How would you recommend me to build?
Gemini: Building a client-side video cropper is a fantastic project that bridges the gap between simple UI work and complex media processing.
Since you want to stick to HTML, CSS, and JS, the architecture effectively splits into two distinct problems:
The UI (The “Ghost” Crop): A visual overlay that lets users select where to crop.
The Engine (The “Real” Crop): The actual processing tool that slices the video file, for which FFmpeg.wasm is the industry standard.
Here is the recommended blueprint for building this.
Engine: FFmpeg.wasm. This is a port of the famous FFmpeg tool to WebAssembly. It runs directly in the browser, allowing you to edit videos without a backend server. +1
UI: Cropper.js (via CDN). While you can write your own resize/drag logic, Cropper.js is battle-tested, handles aspect ratios, and touch support perfectly. We will “trick” it into working with video.
The hardest part of this project isn’t the cropping itself; it’s the math. The video displayed on your screen (e.g., 500px wide) is rarely the size of the actual video file (e.g., 1920px wide 4k video).
You must calculate a Scale Factor: Scale= Displayed Video Width Original Video Width
When the user draws a crop box, you multiply their coordinates by this scale factor before sending them to FFmpeg.
Here is a complete, copy-pasteable structure to get you started.
Step A: The Setup (HTML)
You need a container to stack the video and the crop overlay perfectly.
HTML <!DOCTYPE html>
Step B: The Logic (app.js) JavaScript const { createFFmpeg, fetchFile } = FFmpeg; const ffmpeg = createFFmpeg({ log: true });
const video = document.getElementById(‘video-element’); const upload = document.getElementById(‘upload’); const cropBtn = document.getElementById(‘crop-btn’); const message = document.getElementById(‘message’);
let cropper;
// 1. Initialize FFmpeg const loadFFmpeg = async () => { message.innerText = ‘Loading FFmpeg…’; await ffmpeg.load(); message.innerText = ‘FFmpeg Ready’; }; loadFFmpeg();
// 2. Handle Video Upload upload.addEventListener(‘change’, (e) => { const file = e.target.files[0]; const url = URL.createObjectURL(file); video.src = url;
// Initialize Cropper.js once video metadata is loaded
video.onloadedmetadata = () => {
if (cropper) cropper.destroy();
// We initialize cropper on the VIDEO element directly
cropper = new Cropper(video, {
viewMode: 1,
movable: false, // Don't move the video, move the crop box
zoomable: false,
rotatable: false,
scalable: false,
crop(event) {
// This updates strictly on UI interaction
},
});
}; });
// 3. The Crop Logic cropBtn.addEventListener(‘click’, async () => { if (!ffmpeg.isLoaded()) { alert(“FFmpeg is still loading…”); return; }
message.innerText = 'Processing... This may take time.';
// A. Get Crop Data from UI
const cropData = cropper.getData(); // Returns {x, y, width, height}
// B. Calculate "Real" Coordinates
// Note: Cropper.js usually handles the scaling for you if initialized on the element
// But if you see offsets, use:
// const scaleX = video.videoWidth / video.offsetWidth;
// const realX = cropData.x * scaleX;
// For this example, we trust cropper.getData() (which usually returns natural dimensions)
const w = Math.round(cropData.width);
const h = Math.round(cropData.height);
const x = Math.round(cropData.x);
const y = Math.round(cropData.y);
// C. Write File to Memory
const file = upload.files[0];
const fileName = 'input.mp4';
ffmpeg.FS('writeFile', fileName, await fetchFile(file));
// D. Run FFmpeg Command
// -vf "crop=w:h:x:y" is the magic filter
await ffmpeg.run('-i', fileName, '-vf', `crop=${w}:${h}:${x}:${y}`, '-c:a', 'copy', 'output.mp4');
// E. Read Result & Download
const data = ffmpeg.FS('readFile', 'output.mp4');
const videoBlob = new Blob([data.buffer], { type: 'video/mp4' });
const videoUrl = URL.createObjectURL(videoBlob);
const outputLink = document.getElementById('output-link');
outputLink.href = videoUrl;
outputLink.style.display = 'block';
message.innerText = 'Done!'; });
This is the #1 blocker for beginners with FFmpeg.wasm. WebAssembly video processing requires a feature called SharedArrayBuffer. Browsers disable this by default for security unless your server sends two specific headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
How to fix this for local development:
You cannot just open index.html file directly in the browser. You must use a local server (like Live Server in VS Code).
You need to use a “drop-in” script like coi-serviceworker (linked in the HTML above). You must download the coi-serviceworker.js file from this GitHub repo and place it next to your index.html. It magically adds the headers for you in the browser.
File Size: Client-side processing is heavy. Processing a 500MB video might crash the browser tab. Limit inputs to small clips (<50MB) for your first version.
Trim First: If you add a “Trim” feature (cut start/end time) before cropping, the file becomes smaller and cropping is faster.
UI Feedback: Show a spinner. FFmpeg processing freezes the UI unless run in a worker, so visual feedback is vital.
Next Step
Would you like me to explain how to add a “Trim” (start/end time) slider to this, so users can shorten the video before processing to make the crop faster?
User: You said
I asked the wrong question but this is great to put it for later. I am actually asking about just trimming. How would you recommend me to build?
Gemini: No problem! That previous guide is great for spatial editing (cropping dimensions), but you are asking for temporal editing (trimming time).
This is actually much easier and significantly faster to build. Because you are just cutting time, you often don’t need to re-encode the video (re-draw every pixel); you just copy the existing data stream.
Here is how to build a Client-Side Video Trimmer using HTML, CSS, JS, and FFmpeg.wasm.
The secret weapon for this build is the FFmpeg flag -c copy.
Normal Render: Decodes video -> Edits -> Encodes video. (Slow, heavy CPU usage).
Stream Copy: Locates the start/end bytes in the file -> Copies them to a new file. (Instant, near-zero CPU usage).
Engine: FFmpeg.wasm (Still the standard).
UI Component: noUiSlider.
Why? HTML5 only has one handle. A trimmer needs two handles (Start and End). Writing a dual-handle slider from scratch is painful; this tiny library handles the touch/drag physics perfectly.
You will need the coi-serviceworker.js file mentioned in the previous answer (to enable SharedArrayBuffer), but the code is much simpler.
Step A: HTML Structure
We need a video player and a slider bar underneath it.
HTML <!DOCTYPE html>
Step B: The Logic (app.js)
The trick here is linking the slider events to the video.currentTime so the user sees exactly what frame they are cutting.
JavaScript const { createFFmpeg, fetchFile } = FFmpeg; const ffmpeg = createFFmpeg({ log: true });
const video = document.getElementById(‘video-element’); const upload = document.getElementById(‘upload’); const slider = document.getElementById(‘slider-container’); const trimBtn = document.getElementById(‘trim-btn’); const message = document.getElementById(‘message’); const lblStart = document.getElementById(‘lbl-start’); const lblEnd = document.getElementById(‘lbl-end’);
let fileData = null; // Store the file for FFmpeg
// 1. Initialize FFmpeg const init = async () => { message.innerText = ‘Loading Engine…’; await ffmpeg.load(); message.innerText = ‘’; trimBtn.disabled = false; }; init();
// 2. Initialize Slider (Hidden initially) noUiSlider.create(slider, { start: [0, 100], connect: true, range: { ‘min’: 0, ‘max’: 100 }, step: 0.1 // Allow 0.1s precision }); slider.setAttribute(‘disabled’, true);
// 3. Handle Upload upload.addEventListener(‘change’, (e) => { const file = e.target.files[0]; fileData = file; video.src = URL.createObjectURL(file); });
// 4. Once Video Metadata Loads (We know duration) video.addEventListener(‘loadedmetadata’, () => { const duration = video.duration;
// Update Slider Range to match Video Duration
slider.noUiSlider.updateOptions({
range: { 'min': 0, 'max': duration },
start: [0, duration] // Start with full selection
});
slider.removeAttribute('disabled');
updateLabels([0, duration]); });
// 5. Sync Slider to Video (The “Preview” Logic) slider.noUiSlider.on(‘update’, (values, handle) => { // Update text labels updateLabels(values); });
slider.noUiSlider.on(‘slide’, (values, handle) => { // When user drags a handle, seek video to that time // handle 0 = start handle, handle 1 = end handle video.currentTime = values[handle]; video.pause(); // Pause so they can see the specific frame });
function updateLabels(values) { lblStart.innerText = parseFloat(values[0]).toFixed(1) + ‘s’; lblEnd.innerText = parseFloat(values[1]).toFixed(1) + ‘s’; }
// 6. The Trimming Logic (FFmpeg) trimBtn.addEventListener(‘click’, async () => { if(!fileData) return;
message.innerText = 'Trimming...';
const values = slider.noUiSlider.get();
const startTime = values[0]; // String, e.g., "10.50"
const endTime = values[1];
const duration = endTime - startTime;
// A. Write file to memory
ffmpeg.FS('writeFile', 'input.mp4', await fetchFile(fileData));
// B. Run Command
// -ss: Seek to start
// -t: Duration to keep (End - Start)
// -c copy: STREAM COPY (Fast! No re-encoding)
// -avoid_negative_ts 1: Helps with sync issues
await ffmpeg.run(
'-ss', startTime.toString(),
'-i', 'input.mp4',
'-t', duration.toString(),
'-c', 'copy',
'output.mp4'
);
// C. Get Result
const data = ffmpeg.FS('readFile', 'output.mp4');
const url = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));
const dl = document.getElementById('download-link');
dl.href = url;
dl.style.display = 'inline-block';
message.innerText = 'Done!'; });
If you use -c copy (Stream Copy), FFmpeg can only cut at “Keyframes” (Intra-frames). These are complete images stored in the video stream.
The Good: It is instant.
The Bad: It is not “frame perfect.” If you ask to cut at 10.5s, but the nearest keyframe is at 8s, the video might start at 8s, or start with a bit of black screen until the 10.5s mark.
How to fix it? If you need frame-perfect cuts (exactly where the user pointed), you must remove -c copy from the command.
Change: [‘-ss’, …, ‘-c’, ‘copy’, ‘output.mp4’]
To: [‘-ss’, …, ‘output.mp4’]
Trade-off: This forces FFmpeg to re-encode the video. It will take 10x-20x longer to process.
Next Step
For a project like this, the UI is usually the tricky part. Would you like me to show you how to add a “Play Selection” button? (A button that plays only the part of the video inside the slider handles, so the user can verify their cut before downloading?)
User: You said
I am using live server in vscode windows and below is what in the console:
’’’
createFFmpeg.js:43 [info] use ffmpeg.wasm v0.11.6
createFFmpeg.js:43 [info] load ffmpeg-core
createFFmpeg.js:43 [info] loading ffmpeg-core
1baa0d2c-5342-4d35-8b29-5245b06214e1:22 Uncaught (in promise) ReferenceError: SharedArrayBuffer is not defined
at 1baa0d2c-5342-4d35-8b29-5245b06214e1:22:175
at createFFmpeg.js:117:20
at u (runtime.js:63:40)
at Generator._invoke (runtime.js:293:22)
at Generator.next (runtime.js:118:21)
at o (ffmpeg.min.js:1:6011)
at c (ffmpeg.min.js:1:6214)
(anonymous) @ 1baa0d2c-5342-4d35-8b29-5245b06214e1:22
(anonymous) @ createFFmpeg.js:117
u @ runtime.js:63
(anonymous) @ runtime.js:293
(anonymous) @ runtime.js:118
o @ ffmpeg.min.js:1
c @ ffmpeg.min.js:1
Promise.then
o @ ffmpeg.min.js:1
c @ ffmpeg.min.js:1
(anonymous) @ ffmpeg.min.js:1
(anonymous) @ ffmpeg.min.js:1
(anonymous) @ createFFmpeg.js:103
init @ app.js:17
(anonymous) @ app.js:21
’’’
Gemini: This is the “Boss Fight” of WebAssembly development. Don’t worry, you aren’t doing anything wrong in your code logic.
The error SharedArrayBuffer is not defined means your browser has disabled the feature for security reasons.
Browsers (Chrome, Edge, Firefox) lock this feature by default to prevent security vulnerabilities (like Spectre). To unlock it, your server must send two specific “Cross-Origin” headers. The default VS Code Live Server does not send these headers.
Here are the two ways to fix this.
Fix 1: The “Drop-in” Fix (Easiest)
You likely added the
Download the file: Go to this GitHub repository: coi-serviceworker.js raw file. (Right-click the page and “Save As…”, or copy all the text and paste it into a new file).
Save it correctly: Save this file as coi-serviceworker.js inside the same folder as your index.html.
Verify your HTML: Make sure this script is at the very top of your <body> or <head>, before your app.js or FFmpeg scripts run.
HTML
Hard Reload: Go to your page, clear cache, and reload (Ctrl + Shift + R). The page might reload itself automatically once; this is normal (it’s the service worker activating the headers).
Fix 2: The “Node Server” Fix (More Robust)
If Fix 1 feels “hacky” or isn’t working, the professional way is to run a tiny custom server instead of the VS Code extension.
Create a file named server.js in your project folder.
Paste this code (requires Node.js installed):
JavaScript const http = require(‘http’); const fs = require(‘fs’); const path = require(‘path’);
const PORT = 3000;
const mimeTypes = { ‘.html’: ‘text/html’, ‘.js’: ‘text/javascript’, ‘.css’: ‘text/css’, ‘.json’: ‘application/json’, ‘.png’: ‘image/png’, ‘.jpg’: ‘image/jpg’, ‘.gif’: ‘image/gif’, ‘.svg’: ‘image/svg+xml’, ‘.wav’: ‘audio/wav’, ‘.mp4’: ‘video/mp4’, ‘.woff’: ‘application/font-woff’, ‘.ttf’: ‘application/font-ttf’, ‘.eot’: ‘application/vnd.ms-fontobject’, ‘.otf’: ‘application/font-otf’, ‘.wasm’: ‘application/wasm’ };
http.createServer(function (request, response) { console.log(‘request ‘, request.url);
let filePath = '.' + request.url;
if (filePath == './') {
filePath = './index.html';
}
const extname = String(path.extname(filePath)).toLowerCase();
const contentType = mimeTypes[extname] || 'application/octet-stream';
fs.readFile(filePath, function(error, content) {
if (error) {
if(error.code == 'ENOENT') {
response.writeHead(404, { 'Content-Type': 'text/html' });
response.end("404 Not Found", 'utf-8');
}
else {
response.writeHead(500);
response.end('Sorry, check with the site admin for error: '+error.code+' ..\n');
}
}
else {
// HERE IS THE MAGIC PART
response.writeHead(200, {
'Content-Type': contentType,
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp'
});
response.end(content, 'utf-8');
}
});
}).listen(PORT);
console.log(Server running at http://127.0.0.1:${PORT}/);
Open your VS Code terminal and run: node server.js
Open your browser to http://localhost:3000. The error will be gone.
Summary
The error is not in your JavaScript logic. It is strictly a server configuration issue.
Try Fix 1 first. Ensure the coi-serviceworker.js file physically exists in your folder.
Use Fix 2 if you want a reliable dev environment without relying on a service worker hack.
User: You said
Thanks it works with fix 1. I would still like to report the console:
(index):40 GET http://localhost:5500/coi-serviceworker.js net::ERR_ABORTED 404 (Not Found)
(index):1 Refused to execute script from ‘http://localhost:5500/coi-serviceworker.js’ because its MIME type (‘text/html’) is not executable, and strict MIME type checking is enabled.
createFFmpeg.js:43 [info] use ffmpeg.wasm v0.11.6
createFFmpeg.js:43 [info] load ffmpeg-core
createFFmpeg.js:43 [info] loading ffmpeg-core
createFFmpeg.js:43 [info] ffmpeg-core loaded
createFFmpeg.js:43 [info] run FS.writeFile input.mp4 <583593963 bytes binary file>
createFFmpeg.js:43 [info] run ffmpeg command: -ss 1024.10 -i input.mp4 -t 417.72 -c copy output.mp4
createFFmpeg.js:43 [fferr] ffmpeg version 9e96b1c Copyright (c) 2000-2020 the FFmpeg developers
createFFmpeg.js:43 [fferr] built with emcc (Emscripten gcc/clang-like replacement) 2.0.8 (d059fd603d0b45b584f634dc2365bc9e9a6ec1dd)
createFFmpeg.js:43 [fferr] configuration: –target-os=none –arch=x86_32 –enable-cross-compile –disable-x86asm –disable-inline-asm –disable-stripping –disable-programs –disable-doc –disable-debug –disable-runtime-cpudetect –disable-autodetect –extra-cflags=’-O3 –closure 1 -I/src/build/include -s USE_PTHREADS=1’ –extra-cxxflags=’-O3 –closure 1 -I/src/build/include -s USE_PTHREADS=1’ –extra-ldflags=’-O3 –closure 1 -I/src/build/include -s USE_PTHREADS=1 -L/src/build/lib’ –pkg-config-flags=–static –nm=llvm-nm –ar=emar –ranlib=emranlib –cc=emcc –cxx=em++ –objcc=emcc –dep-cc=emcc –enable-gpl –enable-nonfree –enable-zlib –enable-libx264 –enable-libx265 –enable-libvpx –enable-libwavpack –enable-libmp3lame –enable-libfdk-aac –enable-libtheora –enable-libvorbis –enable-libfreetype –enable-libopus –enable-libwebp –enable-libass –enable-libfribidi
createFFmpeg.js:43 [fferr] libavutil 56. 51.100 / 56. 51.100
createFFmpeg.js:43 [fferr] libavcodec 58. 91.100 / 58. 91.100
createFFmpeg.js:43 [fferr] libavformat 58. 45.100 / 58. 45.100
createFFmpeg.js:43 [fferr] libavdevice 58. 10.100 / 58. 10.100
createFFmpeg.js:43 [fferr] libavfilter 7. 85.100 / 7. 85.100
createFFmpeg.js:43 [fferr] libswscale 5. 7.100 / 5. 7.100
createFFmpeg.js:43 [fferr] libswresample 3. 7.100 / 3. 7.100
createFFmpeg.js:43 [fferr] libpostproc 55. 7.100 / 55. 7.100
createFFmpeg.js:43 [fferr] Input #0, mov,mp4,m4a,3gp,3g2,mj2, from ‘input.mp4’:
createFFmpeg.js:43 [fferr] Metadata:
createFFmpeg.js:43 [fferr] major_brand : mp42
createFFmpeg.js:43 [fferr] minor_version : 0
createFFmpeg.js:43 [fferr] compatible_brands: isommp42
createFFmpeg.js:43 [fferr] creation_time : 2015-11-29T05:18:44.000000Z
createFFmpeg.js:43 [fferr] Duration: 00:24:01.82, start: 0.000000, bitrate: 3238 kb/s
createFFmpeg.js:43 [fferr] Stream #0:0(und): Video: h264 (High) (avc1 / 0x31637661), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], 3043 kb/s, 23.98 fps, 23.98 tbr, 24k tbn, 47.95 tbc (default)
createFFmpeg.js:43 [fferr] Metadata:
createFFmpeg.js:43 [fferr] handler_name : VideoHandler
createFFmpeg.js:43 [fferr] Stream #0:1(und): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 191 kb/s (default)
createFFmpeg.js:43 [fferr] Metadata:
createFFmpeg.js:43 [fferr] creation_time : 2015-11-29T05:19:08.000000Z
createFFmpeg.js:43 [fferr] handler_name : IsoMedia File Produced by Google, 5-11-2011
createFFmpeg.js:43 [fferr] Output #0, mp4, to ‘output.mp4’:
createFFmpeg.js:43 [fferr] Metadata:
createFFmpeg.js:43 [fferr] major_brand : mp42
createFFmpeg.js:43 [fferr] minor_version : 0
createFFmpeg.js:43 [fferr] compatible_brands: isommp42
createFFmpeg.js:43 [fferr] encoder : Lavf58.45.100
createFFmpeg.js:43 [fferr] Stream #0:0(und): Video: h264 (High) (avc1 / 0x31637661), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], q=2-31, 3043 kb/s, 23.98 fps, 23.98 tbr, 24k tbn, 24k tbc (default)
createFFmpeg.js:43 [fferr] Metadata:
createFFmpeg.js:43 [fferr] handler_name : VideoHandler
createFFmpeg.js:43 [fferr] Stream #0:1(und): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 191 kb/s (default)
createFFmpeg.js:43 [fferr] Metadata:
createFFmpeg.js:43 [fferr] creation_time : 2015-11-29T05:19:08.000000Z
createFFmpeg.js:43 [fferr] handler_name : IsoMedia File Produced by Google, 5-11-2011
createFFmpeg.js:43 [fferr] Stream mapping:
createFFmpeg.js:43 [fferr] Stream #0:0 -> #0:0 (copy)
createFFmpeg.js:43 [fferr] Stream #0:1 -> #0:1 (copy)
createFFmpeg.js:43 [fferr] frame= 2112 fps=0.0 q=-1.0 size= 34816kB time=00:01:27.60 bitrate=3255.8kbits/s speed= 173x
createFFmpeg.js:43 [fferr] frame= 5539 fps=5413 q=-1.0 size= 88832kB time=00:03:50.75 bitrate=3153.6kbits/s speed= 226x
createFFmpeg.js:43 [fferr] frame= 8275 fps=5432 q=-1.0 size= 139520kB time=00:05:44.78 bitrate=3314.9kbits/s speed= 226x
createFFmpeg.js:43 [fferr] frame=10030 fps=5727 q=-1.0 Lsize= 158647kB time=00:06:57.69 bitrate=3111.4kbits/s speed= 239x
createFFmpeg.js:43 [fferr] video:148604kB audio:9805kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.150325%
createFFmpeg.js:43 [ffout] FFMPEG_END
createFFmpeg.js:43 [info] run FS.readFile output.mp4
Gemini: This is a success!
Despite the red error at the top, your FFmpeg logs confirm that the video trimmer is working, and it is working incredibly fast.
Here is the breakdown of what your logs are telling you:
GET http://localhost:5500/coi-serviceworker.js net::ERR_ABORTED 404 (Not Found)
This means the browser cannot find the coi-serviceworker.js file.
Why is it working then? Chrome sometimes relaxes security rules for localhost (your local computer), allowing SharedArrayBuffer to work even without the headers.
The Fix: Make sure coi-serviceworker.js is in the exact same folder as your index.html. Currently, the browser thinks it’s missing. You should fix this before you try to put this website online, or it will break.
Your FFmpeg logs are beautiful. Look at this line:
speed= 239x
This confirms that the Stream Copy strategy (-c copy) is active.
Normal processing: Usually runs at 1x or 0.5x speed (slower than real-time).
Your processing: Ran at 239x speed. You processed a 7-minute chunk (time=00:06:57) in just a couple of seconds.
Why was it so fast?
You successfully bypassed the “Re-encoding” process. Instead of redrawing every pixel, you just told the computer to copy the data packets from A to B.
Summary of your Logs Log Segment Meaning Verdict ERR_ABORTED 404 The security script file is missing from your folder. Fix this (Check file location) -c copy output.mp4 You are using “Stream Copy” mode. Perfect speed= 239x The engine is flying. Extremely efficient. Perfect FFMPEG_END The process finished without crashing. Success Export to Sheets Next Step: User Experience
Since your engine is working perfectly, you might notice that when you trim the video, the browser freezes for a second or two while it works.
Would you like to add a Progress Bar? FFmpeg.wasm has a built-in logger that reports progress (like time=00:03:50). We can use that to show a percentage bar (e.g., “Processing: 45%”) so the user doesn’t think the app crashed.
User: You said
That’s done, back to the content, sometimes sliding the bar is hard to be precise. Can we add to be also to edit the timer?
Gemini: Yes, absolutely. Sliders are great for “feeling” the edit, but terrible for precision (e.g., trying to land exactly on 10.50s).
Adding editable input boxes creates a Two-Way Binding:
Slider moves -> Updates the numbers.
Typing numbers -> Jumps the slider (and the video frame) to that exact spot.
Here is how to upgrade your UI and Code.
Replace your old time-display div with this new structure using fields.
HTML
We need to connect these inputs to the slider. The logic must handle “collisions” (so the start time cannot be set higher than the end time).
Add/Replace this logic in your app.js:
JavaScript // 1. Select the new inputs const valStart = document.getElementById(‘val-start’); const valEnd = document.getElementById(‘val-end’);
// … [Existing init code] …
// 2. UPDATED: Sync Slider -> Inputs (Add this inside your existing slider setup) slider.noUiSlider.on(‘update’, (values, handle) => { const value = parseFloat(values[handle]).toFixed(2);
if (handle === 0) {
valStart.value = value;
} else {
valEnd.value = value;
} });
// 3. NEW: Sync Inputs -> Slider // We listen for ‘change’ (when user hits enter or clicks away) const inputs = [valStart, valEnd];
inputs.forEach((input, handle) => { input.addEventListener(‘change’, function () { // A. Get the current slider values let currentValues = slider.noUiSlider.get(); let newVal = parseFloat(this.value);
// B. Basic Validation
// If changing Start (handle 0), it can't be >= End
if (handle === 0 && newVal >= parseFloat(currentValues[1])) {
newVal = parseFloat(currentValues[1]) - 0.1; // Push it back slightly
}
// If changing End (handle 1), it can't be <= Start
if (handle === 1 && newVal <= parseFloat(currentValues[0])) {
newVal = parseFloat(currentValues[0]) + 0.1;
}
// C. Update the Slider (This automatically updates the video preview too!)
// 'handle' is 0 for start, 1 for end.
// We pass 'null' for the handle we aren't changing.
if (handle === 0) {
slider.noUiSlider.set([newVal, null]);
} else {
slider.noUiSlider.set([null, newVal]);
}
// D. Force Video Preview Update immediately
video.currentTime = newVal;
}); });
How it works now
Precision: You can now type 12.45 and hit Enter. The slider handles will jump exactly to that position.
Safety: If the video ends at 20s and the user tries to type 25s as the start time, noUiSlider (which knows the max range) will automatically clamp it to the max duration, preventing errors.
Next Step: Visual Polish?
Right now, you are building a very functional “Dev” tool. To make it feel like a real product (like Instagram or TikTok’s trimmer), we could display thumbnails underneath the slider track.
Is that something you want to explore, or would you prefer to focus on adding the “Crop” functionality back in so you have both Trimming + Cropping?
User: You said
write me the full app.js
Gemini: Here is the consolidated app.js. This script combines the FFmpeg engine, the dual-handle slider logic, the two-way numerical input binding, and the video preview sync.
Make sure you have coi-serviceworker.js in your folder and your HTML elements match the IDs used here.
JavaScript /**
const { createFFmpeg, fetchFile } = FFmpeg; const ffmpeg = createFFmpeg({ log: true });
// UI Element Selectors const video = document.getElementById(‘video-element’); const upload = document.getElementById(‘upload’); const slider = document.getElementById(‘slider-container’); const trimBtn = document.getElementById(‘trim-btn’); const message = document.getElementById(‘message’); const valStart = document.getElementById(‘val-start’); const valEnd = document.getElementById(‘val-end’); const downloadLink = document.getElementById(‘download-link’);
let fileData = null;
/**
/**
/**
fileData = file; const url = URL.createObjectURL(file); video.src = url; downloadLink.style.display = ‘none’; message.innerText = ‘Video loaded. Set your trim range.’; });
/**
Triggered when video duration is known */ video.addEventListener(‘loadedmetadata’, () => { const duration = video.duration;
// Update slider to match video length slider.noUiSlider.updateOptions({ range: { ‘min’: 0, ‘max’: duration }, start: [0, duration] }); slider.removeAttribute(‘disabled’);
valStart.value = 0; valEnd.value = duration.toFixed(2); });
/**
When slider moves, update input boxes and video frame */ slider.noUiSlider.on(‘update’, (values, handle) => { const value = parseFloat(values[handle]);
if (handle === 0) { valStart.value = value.toFixed(2); } else { valEnd.value = value.toFixed(2); } });
// Seek video preview when dragging slider slider.noUiSlider.on(‘slide’, (values, handle) => { video.currentTime = parseFloat(values[handle]); video.pause(); });
/**
When user types a number, update the slider and video frame */ [valStart, valEnd].forEach((input, handle) => { input.addEventListener(‘change’, function() { let newVal = parseFloat(this.value); let currentValues = slider.noUiSlider.get().map(Number); const duration = video.duration;
// Validation: Clamp to video duration
if (newVal < 0) newVal = 0;
if (newVal > duration) newVal = duration;
// Validation: Prevent handles from crossing
if (handle === 0 && newVal >= currentValues[1]) {
newVal = currentValues[1] - 0.1;
} else if (handle === 1 && newVal <= currentValues[0]) {
newVal = currentValues[0] + 0.1;
}
this.value = newVal.toFixed(2);
// Update Slider handles
let setArray = [null, null];
setArray[handle] = newVal;
slider.noUiSlider.set(setArray);
// Update Video Preview
video.currentTime = newVal; }); });
/**
trimBtn.disabled = true; message.innerText = ‘Processing… Please wait.’;
const start = valStart.value; const end = valEnd.value; const duration = (parseFloat(end) - parseFloat(start)).toFixed(2);
// Write file to FFmpeg’s Virtual File System const inputName = ‘input.mp4’; const outputName = ‘output.mp4’; ffmpeg.FS(‘writeFile’, inputName, await fetchFile(fileData));
/**
// Read resulting file const data = ffmpeg.FS(‘readFile’, outputName);
// Create download URL const blob = new Blob([data.buffer], { type: ‘video/mp4’ }); const url = URL.createObjectURL(blob);
downloadLink.href = url; downloadLink.style.display = ‘inline-block’; message.innerText = ‘Trim complete!’; trimBtn.disabled = false;
// Cleanup virtual FS to save memory ffmpeg.FS(‘unlink’, inputName); ffmpeg.FS(‘unlink’, outputName); });
Pro Tip for Windows Users
If you find that the Stream Copy (-c copy) makes the start of your trimmed video slightly “frozen” for a second, it’s because it’s waiting for the first keyframe. To fix this, you can change the order of the command to:
JavaScript // Re-encoding version (Slower, but frame-accurate) await ffmpeg.run(‘-i’, inputName, ‘-ss’, start, ‘-to’, end, outputName);
Next Step
Your trimmer is now fully functional with precision controls! Would you like to add a “Status Progress” listener so the user can see exactly how many frames have been processed in real-time?
User: You said
Is there something wrong?
index.html:
’’’
<!DOCTYPE html>
’’’
app.js:
’’’
/**
* VIDEO TRIMMER ENGINE (HTML5 + FFmpeg.wasm)
*/
const { createFFmpeg, fetchFile } = FFmpeg;
const ffmpeg = createFFmpeg({ log: true });
// UI Element Selectors
const video = document.getElementById(‘video-element’);
const upload = document.getElementById(‘upload’);
const slider = document.getElementById(‘slider-container’);
const trimBtn = document.getElementById(‘trim-btn’);
const message = document.getElementById(‘message’);
const valStart = document.getElementById(‘val-start’);
const valEnd = document.getElementById(‘val-end’);
const downloadLink = document.getElementById(‘download-link’);
let fileData = null;
/**
* 1. INITIALIZE FFMPEG
*/
const initFFmpeg = async () => {
try {
message.innerText = ‘Loading FFmpeg Engine…’;
await ffmpeg.load();
message.innerText = ‘Engine Ready.’;
trimBtn.disabled = false;
} catch (err) {
message.innerText = ‘Error loading FFmpeg. Check console.’;
console.error(err);
}
};
initFFmpeg();
/**
* 2. INITIALIZE SLIDER (noUiSlider)
*/
noUiSlider.create(slider, {
start: [0, 10], // Initial dummy values
connect: true,
range: { ‘min’: 0, ‘max’: 10 },
step: 0.01,
tooltips: false
});
slider.setAttribute(‘disabled’, true); // Disable until video loads
/**
* 3. FILE UPLOAD HANDLER
*/
upload.addEventListener(‘change’, (e) => {
const file = e.target.files[0];
if (!file) return;
fileData = file;
const url = URL.createObjectURL(file);
video.src = url;
downloadLink.style.display = ‘none’;
message.innerText = ‘Video loaded. Set your trim range.’;
});
/**
* 4. VIDEO METADATA HANDLER
* Triggered when video duration is known
*/
video.addEventListener(‘loadedmetadata’, () => {
const duration = video.duration;
// Update slider to match video length
slider.noUiSlider.updateOptions({
range: { ‘min’: 0, ‘max’: duration },
start: [0, duration]
});
slider.removeAttribute(‘disabled’);
valStart.value = 0;
valEnd.value = duration.toFixed(2);
});
/**
* 5. SLIDER -> UI BINDING
* When slider moves, update input boxes and video frame
*/
slider.noUiSlider.on(‘update’, (values, handle) => {
const value = parseFloat(values[handle]);
if (handle === 0) {
valStart.value = value.toFixed(2);
} else {
valEnd.value = value.toFixed(2);
}
});
// Seek video preview when dragging slider
slider.noUiSlider.on(‘slide’, (values, handle) => {
video.currentTime = parseFloat(values[handle]);
video.pause();
});
/**
* 6. INPUT BOX -> SLIDER BINDING (Precision Editing)
* When user types a number, update the slider and video frame
*/
[valStart, valEnd].forEach((input, handle) => {
input.addEventListener(‘change’, function() {
let newVal = parseFloat(this.value);
let currentValues = slider.noUiSlider.get().map(Number);
const duration = video.duration;
// Validation: Clamp to video duration
if (newVal < 0) newVal = 0;
if (newVal > duration) newVal = duration;
// Validation: Prevent handles from crossing
if (handle === 0 && newVal >= currentValues[1]) {
newVal = currentValues[1] - 0.1;
} else if (handle === 1 && newVal <= currentValues[0]) {
newVal = currentValues[0] + 0.1;
}
this.value = newVal.toFixed(2);
// Update Slider handles
let setArray = [null, null];
setArray[handle] = newVal;
slider.noUiSlider.set(setArray);
// Update Video Preview
video.currentTime = newVal;
});
});
/**
* 7. THE TRIM ACTION (FFmpeg Execution)
*/
trimBtn.addEventListener(‘click’, async () => {
| if (!fileData | !ffmpeg.isLoaded()) return; |
trimBtn.disabled = true;
message.innerText = ‘Processing… Please wait.’;
const start = valStart.value;
const end = valEnd.value;
const duration = (parseFloat(end) - parseFloat(start)).toFixed(2);
// Write file to FFmpeg’s Virtual File System
const inputName = ‘input.mp4’;
const outputName = ‘output.mp4’;
ffmpeg.FS(‘writeFile’, inputName, await fetchFile(fileData));
/**
* COMMAND EXPLAINED:
* -ss : Start time (placed before -i for faster seeking)
* -i : Input file
* -t : Duration to cut
* -c copy : Stream Copy (Fastest, no re-encoding)
*/
await ffmpeg.run(
‘-ss’, start,
‘-i’, inputName,
‘-t’, duration,
‘-c’, ‘copy’,
outputName
);
// Read resulting file
const data = ffmpeg.FS(‘readFile’, outputName);
// Create download URL
const blob = new Blob([data.buffer], { type: ‘video/mp4’ });
const url = URL.createObjectURL(blob);
downloadLink.href = url;
downloadLink.style.display = ‘inline-block’;
message.innerText = ‘Trim complete!’;
trimBtn.disabled = false;
// Cleanup virtual FS to save memory
ffmpeg.FS(‘unlink’, inputName);
ffmpeg.FS(‘unlink’, outputName);
});
’’’
Gemini: You’re very close! The logic in your app.js is solid, but you have a few missing pieces in your HTML that are causing the JavaScript to crash.
Specifically, your script is looking for a Trim Button and a Download Link that don’t exist in your HTML file yet. Because those variables come back as null, the script stops running as soon as it tries to disable the button.
The Fix
You need to add the button and the download link to your index.html. Here is the corrected body section:
HTML
What was happening?
In your app.js, the very first thing initFFmpeg() does is: trimBtn.disabled = false;
Because the button wasn’t in the HTML, trimBtn was null. Trying to set a property on null throws a TypeError, which kills the rest of your script, meaning the slider and upload functionality never even get a chance to initialize.
A Quick Note on the Service Worker
You switched to the CDN version of the service worker:
This is a clever way to do it! Just remember that if you see the SharedArrayBuffer error again, it’s usually because the service worker hasn’t “registered” yet. Simply refresh the page twice and it should clear up.
Next Step
Now that the UI is linked, would you like me to show you how to add a Loading Spinner or a Progress Percentage? Since FFmpeg can take a few seconds on larger files, it’s helpful to show the user that the app is actually working!
User: You said
I tried to put it on github page but failed to load, can you check?
’’’
layout: post
title: Video Trimmer in HTML CSS JS
description: A video trimmer using only html css js through ffmpeg wasm.
featuredimage: /assets/images/icon/ftcut.png
category: tools
tags: [tools, video, trimmer, html css, js, json]
canonicalurl: https://0fajarpurnama0.github.io/tools/2022/12/15/video-trimmer
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JS Video Trimmer</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.css">
<style>
body { font-family: sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; }
video { width: 100%; margin-bottom: 20px; border-radius: 8px; background: #000; }
/* The Slider Container */
#slider-container { margin: 0 10px 30px 10px; }
/* Visual Styles */
.controls { display: flex; gap: 10px; align-items: center; }
.time-display { font-family: monospace; font-weight: bold; }
#download-link { display: none; color: white; background: #007bff; padding: 10px 20px; text-decoration: none; border-radius: 4px;}
button { padding: 10px 20px; cursor: pointer; }
/* Add this simple CSS to make them look nice */
.controls-wrapper {
display: flex;
gap: 20px;
margin-bottom: 20px;
align-items: center;
justify-content: center;
}
.input-group {
display: flex;
flex-direction: column;
}
.input-group input {
width: 80px;
padding: 5px;
font-size: 16px;
font-family: monospace;
}
</style>
</head>
<body>
<input type="file" id="upload" accept="video/*"><br><br>
<video id="video-element" controls playsinline></video>
<div id="slider-container"></div>
<div class="controls-wrapper">
<div class="input-group">
<label>Start (sec)</label>
<input type="number" id="val-start" step="0.01" min="0">
</div>
<button id="trim-btn" disabled>Trim Video</button>
<div class="input-group">
<label>End (sec)</label>
<input type="number" id="val-end" step="0.01" min="0">
</div>
</div>
<a id="download-link" download="trimmed.mp4" style="display:none; margin-top: 10px; text-align: center;">Download Trimmed Video</a>
<p id="message"></p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.js"></script>
<script src="https://unpkg.com/coi-serviceworker@0.1.7/coi-serviceworker.min.js"></script>
<script src="https://unpkg.com/@ffmpeg/ffmpeg@0.11.6/dist/ffmpeg.min.js"></script>
<script src="app.js"></script>
<script>
/**
* VIDEO TRIMMER ENGINE (HTML5 + FFmpeg.wasm)
*/
const { createFFmpeg, fetchFile } = FFmpeg;
const ffmpeg = createFFmpeg({ log: true });
// UI Element Selectors
const video = document.getElementById('video-element');
const upload = document.getElementById('upload');
const slider = document.getElementById('slider-container');
const trimBtn = document.getElementById('trim-btn');
const message = document.getElementById('message');
const valStart = document.getElementById('val-start');
const valEnd = document.getElementById('val-end');
const downloadLink = document.getElementById('download-link');
let fileData = null;
/**
* 1. INITIALIZE FFMPEG
*/
const initFFmpeg = async () => {
try {
message.innerText = 'Loading FFmpeg Engine...';
await ffmpeg.load();
message.innerText = 'Engine Ready.';
trimBtn.disabled = false;
} catch (err) {
message.innerText = 'Error loading FFmpeg. Check console.';
console.error(err);
}
};
initFFmpeg();
/**
* 2. INITIALIZE SLIDER (noUiSlider)
*/
noUiSlider.create(slider, {
start: [0, 10], // Initial dummy values
connect: true,
range: { 'min': 0, 'max': 10 },
step: 0.01,
tooltips: false
});
slider.setAttribute('disabled', true); // Disable until video loads
/**
* 3. FILE UPLOAD HANDLER
*/
upload.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
fileData = file;
const url = URL.createObjectURL(file);
video.src = url;
downloadLink.style.display = 'none';
message.innerText = 'Video loaded. Set your trim range.';
});
/**
* 4. VIDEO METADATA HANDLER
* Triggered when video duration is known
*/
video.addEventListener('loadedmetadata', () => {
const duration = video.duration;
// Update slider to match video length
slider.noUiSlider.updateOptions({
range: { 'min': 0, 'max': duration },
start: [0, duration]
});
slider.removeAttribute('disabled');
valStart.value = 0;
valEnd.value = duration.toFixed(2);
});
/**
* 5. SLIDER -> UI BINDING
* When slider moves, update input boxes and video frame
*/
slider.noUiSlider.on('update', (values, handle) => {
const value = parseFloat(values[handle]);
if (handle === 0) {
valStart.value = value.toFixed(2);
} else {
valEnd.value = value.toFixed(2);
}
});
// Seek video preview when dragging slider
slider.noUiSlider.on('slide', (values, handle) => {
video.currentTime = parseFloat(values[handle]);
video.pause();
});
/**
* 6. INPUT BOX -> SLIDER BINDING (Precision Editing)
* When user types a number, update the slider and video frame
*/
[valStart, valEnd].forEach((input, handle) => {
input.addEventListener('change', function() {
let newVal = parseFloat(this.value);
let currentValues = slider.noUiSlider.get().map(Number);
const duration = video.duration;
// Validation: Clamp to video duration
if (newVal < 0) newVal = 0;
if (newVal > duration) newVal = duration;
// Validation: Prevent handles from crossing
if (handle === 0 && newVal >= currentValues[1]) {
newVal = currentValues[1] - 0.1;
} else if (handle === 1 && newVal <= currentValues[0]) {
newVal = currentValues[0] + 0.1;
}
this.value = newVal.toFixed(2);
// Update Slider handles
let setArray = [null, null];
setArray[handle] = newVal;
slider.noUiSlider.set(setArray);
// Update Video Preview
video.currentTime = newVal;
});
});
/**
* 7. THE TRIM ACTION (FFmpeg Execution)
*/
trimBtn.addEventListener('click', async () => {
if (!fileData || !ffmpeg.isLoaded()) return;
trimBtn.disabled = true;
message.innerText = 'Processing... Please wait.';
const start = valStart.value;
const end = valEnd.value;
const duration = (parseFloat(end) - parseFloat(start)).toFixed(2);
// Write file to FFmpeg's Virtual File System
const inputName = 'input.mp4';
const outputName = 'output.mp4';
ffmpeg.FS('writeFile', inputName, await fetchFile(fileData));
/**
* COMMAND EXPLAINED:
* -ss : Start time (placed before -i for faster seeking)
* -i : Input file
* -t : Duration to cut
* -c copy : Stream Copy (Fastest, no re-encoding)
*/
await ffmpeg.run(
'-ss', start,
'-i', inputName,
'-t', duration,
'-c', 'copy',
outputName
);
// Read resulting file
const data = ffmpeg.FS('readFile', outputName);
// Create download URL
const blob = new Blob([data.buffer], { type: 'video/mp4' });
const url = URL.createObjectURL(blob);
downloadLink.href = url;
downloadLink.style.display = 'inline-block';
message.innerText = 'Trim complete!';
trimBtn.disabled = false;
// Cleanup virtual FS to save memory
ffmpeg.FS('unlink', inputName);
ffmpeg.FS('unlink', outputName);
});
</script>
</body>
</html>’’’
Gemini: The issue is almost certainly due to where you placed the coi-serviceworker.js file.
The Problem: Service Worker Scope
You placed the file in /assets/js/3rdparty/coi-serviceworker-0.1.7.js. Your page is located at /tools/2022/12/15/video-trimmer.
Rule of the Web: A Service Worker can only control pages that are inside the same folder or deeper.
A worker at /assets/js/… cannot control a page at /tools/….
Therefore, the worker never activates, the security headers are never added, and SharedArrayBuffer remains broken.
The Fix
To fix this on GitHub Pages, you must move the service worker file to the Root of your repository.
Step 1: Move the File
Move coi-serviceworker-0.1.7.js (or rename it to just coi-serviceworker.js for simplicity) to the very top level of your GitHub repo, so it is accessible at: https://0fajarpurnama0.github.io/coi-serviceworker.js
Step 2: Update Your HTML
Change the script reference in your Jekyll post to point to the root:
HTML
Why this works
When the file is at the root (/), it has “Scope” over the entire domain (/). It can now intercept the request for your /tools/… page and inject the required Cross-Origin-Embedder-Policy headers.
One more Jekyll Tip
Since you are using Jekyll, sometimes the liquid template engine tries to parse JavaScript syntax (like ). If you see weird rendering errors, wrap your JavaScript block in raw tags:
HTML
// Your FFmpeg and Slider code here…
</script>
User: You said
The engine loads now but when uploading video it does load to the player:
’’’
layout: post
title: Video Trimmer in HTML CSS JS
description: A video trimmer using only html css js through ffmpeg wasm.
featuredimage: /assets/images/icon/ftcut.png
category: tools
tags: [tools, video, trimmer, html css, js, json]
canonicalurl: https://0fajarpurnama0.github.io/tools/2022/12/15/video-trimmer
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JS Video Trimmer</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.css">
<style>
body { font-family: sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; }
video { width: 100%; margin-bottom: 20px; border-radius: 8px; background: #000; }
/* The Slider Container */
#slider-container { margin: 0 10px 30px 10px; }
/* Visual Styles */
.controls { display: flex; gap: 10px; align-items: center; }
.time-display { font-family: monospace; font-weight: bold; }
#download-link { display: none; color: white; background: #007bff; padding: 10px 20px; text-decoration: none; border-radius: 4px;}
button { padding: 10px 20px; cursor: pointer; }
/* Add this simple CSS to make them look nice */
.controls-wrapper {
display: flex;
gap: 20px;
margin-bottom: 20px;
align-items: center;
justify-content: center;
}
.input-group {
display: flex;
flex-direction: column;
}
.input-group input {
width: 80px;
padding: 5px;
font-size: 16px;
font-family: monospace;
}
</style>
</head>
<body>
<input type="file" id="upload" accept="video/*"><br><br>
<video id="video-element" controls playsinline></video>
<div id="slider-container"></div>
<div class="controls-wrapper">
<div class="input-group">
<label>Start (sec)</label>
<input type="number" id="val-start" step="0.01" min="0">
</div>
<button id="trim-btn" disabled>Trim Video</button>
<div class="input-group">
<label>End (sec)</label>
<input type="number" id="val-end" step="0.01" min="0">
</div>
</div>
<a id="download-link" download="trimmed.mp4" style="display:none; margin-top: 10px; text-align: center;">Download Trimmed Video</a>
<p id="message"></p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.js"></script>
<script src="https://unpkg.com/coi-serviceworker@0.1.7/coi-serviceworker.min.js"></script>
<script src="https://unpkg.com/@ffmpeg/ffmpeg@0.11.6/dist/ffmpeg.min.js"></script>
<script src="app.js"></script>
<script>
/**
* VIDEO TRIMMER ENGINE (HTML5 + FFmpeg.wasm)
*/
const { createFFmpeg, fetchFile } = FFmpeg;
const ffmpeg = createFFmpeg({ log: true });
// UI Element Selectors
const video = document.getElementById('video-element');
const upload = document.getElementById('upload');
const slider = document.getElementById('slider-container');
const trimBtn = document.getElementById('trim-btn');
const message = document.getElementById('message');
const valStart = document.getElementById('val-start');
const valEnd = document.getElementById('val-end');
const downloadLink = document.getElementById('download-link');
let fileData = null;
/**
* 1. INITIALIZE FFMPEG
*/
const initFFmpeg = async () => {
try {
message.innerText = 'Loading FFmpeg Engine...';
await ffmpeg.load();
message.innerText = 'Engine Ready.';
trimBtn.disabled = false;
} catch (err) {
message.innerText = 'Error loading FFmpeg. Check console.';
console.error(err);
}
};
initFFmpeg();
/**
* 2. INITIALIZE SLIDER (noUiSlider)
*/
noUiSlider.create(slider, {
start: [0, 10], // Initial dummy values
connect: true,
range: { 'min': 0, 'max': 10 },
step: 0.01,
tooltips: false
});
slider.setAttribute('disabled', true); // Disable until video loads
/**
* 3. FILE UPLOAD HANDLER
*/
upload.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
fileData = file;
const url = URL.createObjectURL(file);
video.src = url;
downloadLink.style.display = 'none';
message.innerText = 'Video loaded. Set your trim range.';
});
/**
* 4. VIDEO METADATA HANDLER
* Triggered when video duration is known
*/
video.addEventListener('loadedmetadata', () => {
const duration = video.duration;
// Update slider to match video length
slider.noUiSlider.updateOptions({
range: { 'min': 0, 'max': duration },
start: [0, duration]
});
slider.removeAttribute('disabled');
valStart.value = 0;
valEnd.value = duration.toFixed(2);
});
/**
* 5. SLIDER -> UI BINDING
* When slider moves, update input boxes and video frame
*/
slider.noUiSlider.on('update', (values, handle) => {
const value = parseFloat(values[handle]);
if (handle === 0) {
valStart.value = value.toFixed(2);
} else {
valEnd.value = value.toFixed(2);
}
});
// Seek video preview when dragging slider
slider.noUiSlider.on('slide', (values, handle) => {
video.currentTime = parseFloat(values[handle]);
video.pause();
});
/**
* 6. INPUT BOX -> SLIDER BINDING (Precision Editing)
* When user types a number, update the slider and video frame
*/
[valStart, valEnd].forEach((input, handle) => {
input.addEventListener('change', function() {
let newVal = parseFloat(this.value);
let currentValues = slider.noUiSlider.get().map(Number);
const duration = video.duration;
// Validation: Clamp to video duration
if (newVal < 0) newVal = 0;
if (newVal > duration) newVal = duration;
// Validation: Prevent handles from crossing
if (handle === 0 && newVal >= currentValues[1]) {
newVal = currentValues[1] - 0.1;
} else if (handle === 1 && newVal <= currentValues[0]) {
newVal = currentValues[0] + 0.1;
}
this.value = newVal.toFixed(2);
// Update Slider handles
let setArray = [null, null];
setArray[handle] = newVal;
slider.noUiSlider.set(setArray);
// Update Video Preview
video.currentTime = newVal;
});
});
/**
* 7. THE TRIM ACTION (FFmpeg Execution)
*/
trimBtn.addEventListener('click', async () => {
if (!fileData || !ffmpeg.isLoaded()) return;
trimBtn.disabled = true;
message.innerText = 'Processing... Please wait.';
const start = valStart.value;
const end = valEnd.value;
const duration = (parseFloat(end) - parseFloat(start)).toFixed(2);
// Write file to FFmpeg's Virtual File System
const inputName = 'input.mp4';
const outputName = 'output.mp4';
ffmpeg.FS('writeFile', inputName, await fetchFile(fileData));
/**
* COMMAND EXPLAINED:
* -ss : Start time (placed before -i for faster seeking)
* -i : Input file
* -t : Duration to cut
* -c copy : Stream Copy (Fastest, no re-encoding)
*/
await ffmpeg.run(
'-ss', start,
'-i', inputName,
'-t', duration,
'-c', 'copy',
outputName
);
// Read resulting file
const data = ffmpeg.FS('readFile', outputName);
// Create download URL
const blob = new Blob([data.buffer], { type: 'video/mp4' });
const url = URL.createObjectURL(blob);
downloadLink.href = url;
downloadLink.style.display = 'inline-block';
message.innerText = 'Trim complete!';
trimBtn.disabled = false;
// Cleanup virtual FS to save memory
ffmpeg.FS('unlink', inputName);
ffmpeg.FS('unlink', outputName);
});
</script>
</body>
</html>’’’
Gemini: This is a Syntax Error in your HTML that is breaking the dependency chain.
The reason the video player isn’t loading is likely not related to FFmpeg or the Service Worker anymore. It is because your Javascript crashes before it can ever “listen” for your file upload.
The Error
Look closely at line 54 of your code:
HTML
What happens:
The browser tries to load nouislider. It fails (404 Not Found) because of the typo js;.
Your app.js runs.
It reaches line 106: noUiSlider.create(slider, {….
CRASH: Uncaught ReferenceError: noUiSlider is not defined.
Result: The script stops immediately. The code that comes after that (specifically upload.addEventListener) is never executed. Your upload button is dead.
The Fix
Remove the semicolon ; from the path:
HTML
A Safety Check for “Strict Mode”
Since you are now using the “Service Worker” (Strict Security Mode), there is a chance the browser might block the video from playing even after you fix the typo above.
If the video loads but shows a black screen or refuses to play, add crossorigin to your video tag:
HTML
This tells the browser: “This video blob is safe to play in strict mode.”
User: You said
Thanks, it works well now. Next is the opposite. What about video concat or combine?
Gemini: Building a video joiner (concatenator) is the perfect logical next step.
Just like with trimming, there are two ways to do this in FFmpeg: the Fast Way (Stream Copy) and the Slow Way (Re-encoding).
Since you are building a web tool, we want the Fast Way (Stream Copy) whenever possible. This method is called the “Demuxer Concatenation.” It stitches videos together without re-compressing them, so it’s instant.
The Challenge: The “List File”
FFmpeg cannot just take file1.mp4 and file2.mp4 as arguments for joining. It requires a special text file that lists the videos.
You must virtually create a file named list.txt inside the browser’s memory.
The content must look like this:
Plaintext file ‘input0.mp4’ file ‘input1.mp4’ file ‘input2.mp4’
Step-by-Step Implementation
Here is how to build it. I will keep the UI simple: a multi-file uploader and a “Merge” button.
We need an input that accepts multiple files (multiple attribute).
HTML <!DOCTYPE html>
The logic here loops through the uploaded files, renames them sequentially (input0.mp4, input1.mp4…), writes them to memory, creates the list.txt, and runs the command.
JavaScript const { createFFmpeg, fetchFile } = FFmpeg; const ffmpeg = createFFmpeg({ log: true });
const upload = document.getElementById(‘upload’); const fileListDisplay = document.getElementById(‘file-list’); const mergeBtn = document.getElementById(‘merge-btn’); const message = document.getElementById(‘message’); const video = document.getElementById(‘video-element’);
let selectedFiles = [];
// 1. Initialize FFmpeg const init = async () => { message.innerText = ‘Loading Engine…’; await ffmpeg.load(); message.innerText = ‘Ready.’; }; init();
// 2. Handle File Selection upload.addEventListener(‘change’, (e) => { selectedFiles = Array.from(e.target.files);
if (selectedFiles.length < 2) {
fileListDisplay.innerHTML = '<span style="color:red">Please select at least 2 videos.</span>';
mergeBtn.disabled = true;
return;
}
// Display list names
fileListDisplay.innerHTML = selectedFiles
.map((f, i) => `<div class="file-item">${i + 1}. ${f.name}</div>`)
.join('');
mergeBtn.disabled = false;
message.innerText = `${selectedFiles.length} videos ready to merge.`; });
// 3. The Merge Logic mergeBtn.addEventListener(‘click’, async () => { if (!ffmpeg.isLoaded()) return;
mergeBtn.disabled = true;
message.innerText = 'Merging... (This might take a moment)';
// A. Write all files to memory & Build list.txt content
let listContent = '';
for (let i = 0; i < selectedFiles.length; i++) {
const fname = `input${i}.mp4`;
// Write the video file to memory
ffmpeg.FS('writeFile', fname, await fetchFile(selectedFiles[i]));
// Add entry to list.txt string
listContent += `file '${fname}'\n`;
}
// B. Write the list.txt file to memory
ffmpeg.FS('writeFile', 'list.txt', listContent);
// C. Run FFmpeg Concatenation Command
// -f concat : Use the concat format
// -safe 0 : Allow reading absolute paths (standard requirement for concat)
// -i list.txt : Input the list file
// -c copy : Stream Copy (Instant join, no re-encoding)
await ffmpeg.run('-f', 'concat', '-safe', '0', '-i', 'list.txt', '-c', 'copy', 'output.mp4');
// D. Read Result & Play
const data = ffmpeg.FS('readFile', 'output.mp4');
const url = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));
video.src = url;
video.style.display = 'block';
message.innerText = 'Merge Complete!';
mergeBtn.disabled = false;
// Cleanup (Important for memory management!)
for (let i = 0; i < selectedFiles.length; i++) {
ffmpeg.FS('unlink', `input${i}.mp4`);
}
ffmpeg.FS('unlink', 'list.txt');
ffmpeg.FS('unlink', 'output.mp4'); });
Important Constraints (The “Gotcha”)
The -c copy method is fast, but it is strict.
Resolution: All videos must have the exact same resolution (e.g., all 1080p).
Codec: All videos must use the same codec (e.g., all H.264).
Frame Rate: All videos must have the same frame rate.
If a user tries to merge a 720p vertical video with a 1080p horizontal video, the output will look glitchy or fail.
How to handle mixed formats? If you want to support mixing different videos, you must remove -c copy.
Command: await ffmpeg.run(‘-f’, ‘concat’, ‘-safe’, ‘0’, ‘-i’, ‘list.txt’, ‘output.mp4’); (Note: removed -c copy)
Result: FFmpeg will automatically re-encode everything to match the first video.
Trade-off: It will be much slower (rendering time) instead of instant.
Next Step
For a robust tool, you usually want to give the user the ability to reorder the list (drag and drop order) before merging. Would you like to see how to make that file list sortable?
User: You said
Yes please, here are the current:
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JS Video Merger</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<h2>Video Merger (Concat)</h2>
<input type="file" id="upload" accept="video/*" multiple>
<div class="file-list" id="file-list">
No files selected.
</div>
<button id="merge-btn" disabled>Merge Videos</button>
<p id="message"></p>
<video id="video-element" controls playsinline></video>
<script src="../coi-serviceworker.js"></script>
<script src="../lib/ffmpeg-0.11.6.min.js"></script>
<script src="script.js"></script>
</body>
</html>
styles.css:
body { font-family: sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; }
.file-list { margin: 20px 0; border: 1px solid #ddd; padding: 10px; min-height: 50px; }
.file-item { padding: 5px; border-bottom: 1px solid #eee; }
button { padding: 10px 20px; cursor: pointer; margin-right: 10px;}
#video-element { width: 100%; margin-top: 20px; background: #000; display: none; }
scripts.js:
const { createFFmpeg, fetchFile } = FFmpeg;
const ffmpeg = createFFmpeg({ log: true });
const upload = document.getElementById('upload');
const fileListDisplay = document.getElementById('file-list');
const mergeBtn = document.getElementById('merge-btn');
const message = document.getElementById('message');
const video = document.getElementById('video-element');
let selectedFiles = [];
// 1. Initialize FFmpeg
const init = async () => {
message.innerText = 'Loading Engine...';
await ffmpeg.load();
message.innerText = 'Ready.';
};
init();
// 2. Handle File Selection
upload.addEventListener('change', (e) => {
selectedFiles = Array.from(e.target.files);
if (selectedFiles.length < 2) {
fileListDisplay.innerHTML = '<span style="color:red">Please select at least 2 videos.</span>';
mergeBtn.disabled = true;
return;
}
// Display list names
fileListDisplay.innerHTML = selectedFiles
.map((f, i) => `<div class="file-item">${i + 1}. ${f.name}</div>`)
.join('');
mergeBtn.disabled = false;
message.innerText = `${selectedFiles.length} videos ready to merge.`;
});
// 3. The Merge Logic
mergeBtn.addEventListener('click', async () => {
if (!ffmpeg.isLoaded()) return;
mergeBtn.disabled = true;
message.innerText = 'Merging... (This might take a moment)';
// A. Write all files to memory & Build list.txt content
let listContent = '';
for (let i = 0; i < selectedFiles.length; i++) {
const fname = `input${i}.mp4`;
// Write the video file to memory
ffmpeg.FS('writeFile', fname, await fetchFile(selectedFiles[i]));
// Add entry to list.txt string
listContent += `file '${fname}'\n`;
}
// B. Write the list.txt file to memory
ffmpeg.FS('writeFile', 'list.txt', listContent);
// C. Run FFmpeg Concatenation Command
// -f concat : Use the concat format
// -safe 0 : Allow reading absolute paths (standard requirement for concat)
// -i list.txt : Input the list file
// -c copy : Stream Copy (Instant join, no re-encoding)
await ffmpeg.run('-f', 'concat', '-safe', '0', '-i', 'list.txt', '-c', 'copy', 'output.mp4');
// D. Read Result & Play
const data = ffmpeg.FS('readFile', 'output.mp4');
const url = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));
video.src = url;
video.style.display = 'block';
message.innerText = 'Merge Complete!';
mergeBtn.disabled = false;
// Cleanup (Important for memory management!)
for (let i = 0; i < selectedFiles.length; i++) {
ffmpeg.FS('unlink', `input${i}.mp4`);
}
ffmpeg.FS('unlink', 'list.txt');
ffmpeg.FS('unlink', 'output.mp4');
});
Gemini: Adding drag-and-drop sorting is a fantastic upgrade. It changes the app from a basic script into a tool that actually feels good to use.
Since you are using modern web standards, we don’t even need any external libraries for this. We can use the native HTML5 Drag and Drop API.
The logic works like this:
Turn each file item into a draggable=”true” element.
When the user drops an item onto another item, we update the selectedFiles array by moving the file to its new index.
We re-render the list visually. Because the selectedFiles array is physically reordered, your FFmpeg merge logic doesn’t need to change at all!
Here are the updates for your CSS and JS files. Your index.html stays exactly the same.
We need to add a few styles to make the items look “grabbable” and provide visual feedback when an item is being dragged.
CSS body { font-family: sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; } .file-list { margin: 20px 0; border: 1px solid #ddd; padding: 10px; min-height: 50px; background: #fafafa; border-radius: 4px; }
/* Updated File Item Styles for Drag & Drop */ .file-item { padding: 10px; border: 1px solid #ccc; margin-bottom: 5px; background: #fff; cursor: grab; display: flex; align-items: center; border-radius: 4px; transition: transform 0.1s ease; } .file-item:active { cursor: grabbing; }
/* The little hamburger icon to indicate dragging */ .drag-handle { margin-right: 15px; color: #888; font-weight: bold; cursor: grab; }
/* Style for the item currently being dragged */ .file-item.dragging { opacity: 0.4; border: 1px dashed #007bff; background: #e9f5ff; }
button { padding: 10px 20px; cursor: pointer; margin-right: 10px; } #video-element { width: 100%; margin-top: 20px; background: #000; display: none; }
Replace your entire script.js with this. I’ve added a renderFileList() function that handles all the drag-and-drop state, array sorting, and re-rendering automatically.
JavaScript const { createFFmpeg, fetchFile } = FFmpeg; const ffmpeg = createFFmpeg({ log: true });
const upload = document.getElementById(‘upload’); const fileListDisplay = document.getElementById(‘file-list’); const mergeBtn = document.getElementById(‘merge-btn’); const message = document.getElementById(‘message’); const video = document.getElementById(‘video-element’);
let selectedFiles = []; let draggedIndex = null; // Keeps track of what we are currently dragging
// 1. Initialize FFmpeg const init = async () => { message.innerText = ‘Loading Engine…’; await ffmpeg.load(); message.innerText = ‘Ready.’; }; init();
// 2. NEW: Function to render the draggable list const renderFileList = () => { fileListDisplay.innerHTML = ‘’; // Clear current list
selectedFiles.forEach((file, index) => {
// Create the element
const div = document.createElement('div');
div.className = 'file-item';
div.draggable = true;
div.innerHTML = `<span class="drag-handle">☰</span> ${index + 1}. ${file.name}`;
// -- DRAG EVENTS --
// When drag starts, remember this item's index and add visual class
div.addEventListener('dragstart', (e) => {
draggedIndex = index;
setTimeout(() => div.classList.add('dragging'), 0); // Timeout allows the drag ghost image to render normally first
});
// When drag ends, remove visual class
div.addEventListener('dragend', () => {
div.classList.remove('dragging');
});
// Allow items to be dropped on top of this element
div.addEventListener('dragover', (e) => {
e.preventDefault();
});
// When an item is dropped here, swap it in the array and re-render
div.addEventListener('drop', (e) => {
e.preventDefault();
const targetIndex = index;
if (draggedIndex === targetIndex) return; // Didn't move
// Extract the dragged file from the array
const draggedFile = selectedFiles[draggedIndex];
selectedFiles.splice(draggedIndex, 1);
// Insert it at the new dropped position
selectedFiles.splice(targetIndex, 0, draggedFile);
// Re-render the UI with the newly sorted array
renderFileList();
});
fileListDisplay.appendChild(div);
}); };
// 3. Handle File Selection upload.addEventListener(‘change’, (e) => { // Add newly selected files to our master array const newFiles = Array.from(e.target.files); selectedFiles = selectedFiles.concat(newFiles);
if (selectedFiles.length < 2) {
fileListDisplay.innerHTML = '<span style="color:red">Please select at least 2 videos.</span>';
mergeBtn.disabled = true;
return;
}
renderFileList(); // Draw the sortable list
mergeBtn.disabled = false;
message.innerText = `${selectedFiles.length} videos ready to merge. Drag to reorder.`; });
// 4. The Merge Logic (Stays exactly the same!) mergeBtn.addEventListener(‘click’, async () => { if (!ffmpeg.isLoaded()) return;
mergeBtn.disabled = true;
message.innerText = 'Merging... (This might take a moment)';
let listContent = '';
// selectedFiles is now guaranteed to be in the order the user dragged them!
for (let i = 0; i < selectedFiles.length; i++) {
const fname = `input${i}.mp4`;
ffmpeg.FS('writeFile', fname, await fetchFile(selectedFiles[i]));
listContent += `file '${fname}'\n`;
}
ffmpeg.FS('writeFile', 'list.txt', listContent);
// Run Concat
await ffmpeg.run('-f', 'concat', '-safe', '0', '-i', 'list.txt', '-c', 'copy', 'output.mp4');
// Read and Output
const data = ffmpeg.FS('readFile', 'output.mp4');
const url = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));
video.src = url;
video.style.display = 'block';
message.innerText = 'Merge Complete!';
mergeBtn.disabled = false;
// Cleanup Memory
for (let i = 0; i < selectedFiles.length; i++) {
ffmpeg.FS('unlink', `input${i}.mp4`);
}
ffmpeg.FS('unlink', 'list.txt');
ffmpeg.FS('unlink', 'output.mp4'); });
Try it out!
When you load the page now, upload a few videos. You’ll see a small “☰” icon next to each file. Click and hold anywhere on the file row to drag it up or down. The list will automatically snap into place, and when you click “Merge,” it will respect that exact order.
Next Step
Right now, the result just plays in the browser. Would you like to add a Download Output button so the user can actually save their combined video to their computer?
User: You said
Yes please, here are the current:
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JS Video Merger</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<h2>Video Merger (Concat)</h2>
<input type="file" id="upload" accept="video/*" multiple>
<div class="file-list" id="file-list">
No files selected.
</div>
<button id="merge-btn" disabled>Merge Videos</button>
<p id="message"></p>
<video id="video-element" controls playsinline></video>
<script src="../coi-serviceworker.js"></script>
<script src="../lib/ffmpeg-0.11.6.min.js"></script>
<script src="scripts.js"></script>
</body>
</html>
styles.css:
body { font-family: sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; }
.file-list { margin: 20px 0; border: 1px solid #ddd; padding: 10px; min-height: 50px; background: #fafafa; border-radius: 4px; }
/* Updated File Item Styles for Drag & Drop */
.file-item {
padding: 10px;
border: 1px solid #ccc;
margin-bottom: 5px;
background: #fff;
cursor: grab;
display: flex;
align-items: center;
border-radius: 4px;
transition: transform 0.1s ease;
}
.file-item:active { cursor: grabbing; }
/* The little hamburger icon to indicate dragging */
.drag-handle { margin-right: 15px; color: #888; font-weight: bold; cursor: grab; }
/* Style for the item currently being dragged */
.file-item.dragging { opacity: 0.4; border: 1px dashed #007bff; background: #e9f5ff; }
button { padding: 10px 20px; cursor: pointer; margin-right: 10px; }
#video-element { width: 100%; margin-top: 20px; background: #000; display: none; }
scripts.js:
const { createFFmpeg, fetchFile } = FFmpeg;
const ffmpeg = createFFmpeg({ log: true });
const upload = document.getElementById('upload');
const fileListDisplay = document.getElementById('file-list');
const mergeBtn = document.getElementById('merge-btn');
const message = document.getElementById('message');
const video = document.getElementById('video-element');
let selectedFiles = [];
let draggedIndex = null; // Keeps track of what we are currently dragging
// 1. Initialize FFmpeg
const init = async () => {
message.innerText = 'Loading Engine...';
await ffmpeg.load();
message.innerText = 'Ready.';
};
init();
// 2. NEW: Function to render the draggable list
const renderFileList = () => {
fileListDisplay.innerHTML = ''; // Clear current list
selectedFiles.forEach((file, index) => {
// Create the element
const div = document.createElement('div');
div.className = 'file-item';
div.draggable = true;
div.innerHTML = `<span class="drag-handle">☰</span> ${index + 1}. ${file.name}`;
// -- DRAG EVENTS --
// When drag starts, remember this item's index and add visual class
div.addEventListener('dragstart', (e) => {
draggedIndex = index;
setTimeout(() => div.classList.add('dragging'), 0); // Timeout allows the drag ghost image to render normally first
});
// When drag ends, remove visual class
div.addEventListener('dragend', () => {
div.classList.remove('dragging');
});
// Allow items to be dropped on top of this element
div.addEventListener('dragover', (e) => {
e.preventDefault();
});
// When an item is dropped here, swap it in the array and re-render
div.addEventListener('drop', (e) => {
e.preventDefault();
const targetIndex = index;
if (draggedIndex === targetIndex) return; // Didn't move
// Extract the dragged file from the array
const draggedFile = selectedFiles[draggedIndex];
selectedFiles.splice(draggedIndex, 1);
// Insert it at the new dropped position
selectedFiles.splice(targetIndex, 0, draggedFile);
// Re-render the UI with the newly sorted array
renderFileList();
});
fileListDisplay.appendChild(div);
});
};
// 3. Handle File Selection
upload.addEventListener('change', (e) => {
// Add newly selected files to our master array
const newFiles = Array.from(e.target.files);
selectedFiles = selectedFiles.concat(newFiles);
if (selectedFiles.length < 2) {
fileListDisplay.innerHTML = '<span style="color:red">Please select at least 2 videos.</span>';
mergeBtn.disabled = true;
return;
}
renderFileList(); // Draw the sortable list
mergeBtn.disabled = false;
message.innerText = `${selectedFiles.length} videos ready to merge. Drag to reorder.`;
});
// 4. The Merge Logic (Stays exactly the same!)
mergeBtn.addEventListener('click', async () => {
if (!ffmpeg.isLoaded()) return;
mergeBtn.disabled = true;
message.innerText = 'Merging... (This might take a moment)';
let listContent = '';
// selectedFiles is now guaranteed to be in the order the user dragged them!
for (let i = 0; i < selectedFiles.length; i++) {
const fname = `input${i}.mp4`;
ffmpeg.FS('writeFile', fname, await fetchFile(selectedFiles[i]));
listContent += `file '${fname}'\n`;
}
ffmpeg.FS('writeFile', 'list.txt', listContent);
// Run Concat
await ffmpeg.run('-f', 'concat', '-safe', '0', '-i', 'list.txt', '-c', 'copy', 'output.mp4');
// Read and Output
const data = ffmpeg.FS('readFile', 'output.mp4');
const url = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));
video.src = url;
video.style.display = 'block';
message.innerText = 'Merge Complete!';
mergeBtn.disabled = false;
// Cleanup Memory
for (let i = 0; i < selectedFiles.length; i++) {
ffmpeg.FS('unlink', `input${i}.mp4`);
}
ffmpeg.FS('unlink', 'list.txt');
ffmpeg.FS('unlink', 'output.mp4');
});
Gemini: Adding a download button is straightforward since you already have the Blob and the URL.createObjectURL() generated for the video player. We just need to attach that exact same URL to an anchor () tag with a download attribute.
Here are the specific updates to make to your three files.
Add the download link right below your message paragraph. I’ve given it an id and set it to hide by default.
HTML
Let’s style that tag so it looks like a nice, clickable button, distinct from the Merge button. Add this to the bottom of your CSS:
CSS /* Style for the download link to make it look like a button / #download-btn { display: inline-block; padding: 10px 20px; background-color: #28a745; / Green color to indicate success */ color: white; text-decoration: none; border-radius: 4px; font-family: sans-serif; font-size: 14px; margin-bottom: 20px; cursor: pointer; } #download-btn:hover { background-color: #218838; }
You need to grab the new button, reset its visibility when new files are uploaded, and finally attach the video data to it when the merge finishes.
Here is your updated JavaScript. Look for the // NEW: comments to see exactly what changed.
JavaScript const { createFFmpeg, fetchFile } = FFmpeg; const ffmpeg = createFFmpeg({ log: true });
const upload = document.getElementById(‘upload’); const fileListDisplay = document.getElementById(‘file-list’); const mergeBtn = document.getElementById(‘merge-btn’); const message = document.getElementById(‘message’); const video = document.getElementById(‘video-element’); // NEW: Grab the download button const downloadBtn = document.getElementById(‘download-btn’);
let selectedFiles = []; let draggedIndex = null;
// 1. Initialize FFmpeg const init = async () => { message.innerText = ‘Loading Engine…’; await ffmpeg.load(); message.innerText = ‘Ready.’; }; init();
// 2. Function to render the draggable list const renderFileList = () => { fileListDisplay.innerHTML = ‘’;
selectedFiles.forEach((file, index) => {
const div = document.createElement('div');
div.className = 'file-item';
div.draggable = true;
div.innerHTML = `<span class="drag-handle">☰</span> ${index + 1}. ${file.name}`;
div.addEventListener('dragstart', (e) => {
draggedIndex = index;
setTimeout(() => div.classList.add('dragging'), 0);
});
div.addEventListener('dragend', () => {
div.classList.remove('dragging');
});
div.addEventListener('dragover', (e) => {
e.preventDefault();
});
div.addEventListener('drop', (e) => {
e.preventDefault();
const targetIndex = index;
if (draggedIndex === targetIndex) return;
const draggedFile = selectedFiles[draggedIndex];
selectedFiles.splice(draggedIndex, 1);
selectedFiles.splice(targetIndex, 0, draggedFile);
renderFileList();
});
fileListDisplay.appendChild(div);
}); };
// 3. Handle File Selection upload.addEventListener(‘change’, (e) => { const newFiles = Array.from(e.target.files); selectedFiles = selectedFiles.concat(newFiles);
// NEW: Hide the download button and video if user adds more files
downloadBtn.style.display = 'none';
video.style.display = 'none';
if (selectedFiles.length < 2) {
fileListDisplay.innerHTML = '<span style="color:red">Please select at least 2 videos.</span>';
mergeBtn.disabled = true;
return;
}
renderFileList();
mergeBtn.disabled = false;
message.innerText = `${selectedFiles.length} videos ready to merge. Drag to reorder.`; });
// 4. The Merge Logic mergeBtn.addEventListener(‘click’, async () => { if (!ffmpeg.isLoaded()) return;
mergeBtn.disabled = true;
message.innerText = 'Merging... (This might take a moment)';
downloadBtn.style.display = 'none'; // Hide button during new merge
let listContent = '';
for (let i = 0; i < selectedFiles.length; i++) {
const fname = `input${i}.mp4`;
ffmpeg.FS('writeFile', fname, await fetchFile(selectedFiles[i]));
listContent += `file '${fname}'\n`;
}
ffmpeg.FS('writeFile', 'list.txt', listContent);
// Run Concat
await ffmpeg.run('-f', 'concat', '-safe', '0', '-i', 'list.txt', '-c', 'copy', 'output.mp4');
// Read and Output
const data = ffmpeg.FS('readFile', 'output.mp4');
const url = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));
video.src = url;
video.style.display = 'block';
// NEW: Setup and show the download button
downloadBtn.href = url;
downloadBtn.download = 'merged_video.mp4';
downloadBtn.style.display = 'inline-block';
message.innerText = 'Merge Complete!';
mergeBtn.disabled = false;
// Cleanup Memory
for (let i = 0; i < selectedFiles.length; i++) {
ffmpeg.FS('unlink', `input${i}.mp4`);
}
ffmpeg.FS('unlink', 'list.txt');
ffmpeg.FS('unlink', 'output.mp4'); });
Would you like me to show you how to add a small “X” or “Remove” button next to each file in the list so users can delete a file if they selected the wrong one?