0

Tạo contact sheet từ video tải về: trình tạo video MiniMax H3 miễn phí, không cần đăng ký

Khi xem lại một clip ngắn, đôi khi bạn chỉ cần đặt ba thời điểm cạnh nhau: đầu đoạn đã chọn, giữa và gần cuối. Một contact sheet giúp so sánh các ảnh tĩnh đó mà không phải tua đi tua lại.

Bài này xây một tiện ích HTML/JavaScript độc lập: chọn video trên máy, đọc metadata, seek lần lượt tới 10%, 50%, 90% thời lượng rồi xuất một PNG. Nó không phân tích toàn bộ chuyển động, không chấm điểm chất lượng và không tìm keyframe của codec.

Các hình kết quả dưới đây đến từ video hình học tự tạo để kiểm thử. Không có lần tạo video Max Maker AI nào được thực hiện cho bài viết. Chạy trên máy bằng HTTP loopback Lưu toàn bộ mã ở phần dưới thành demo/contact-sheet.html. Từ thư mục chứa thư mục demo, chạy một static server chỉ lắng nghe trên loopback:

python3 -m http.server 8893
--bind 127.0.0.1 Mở http://127.0.0.1:8893/demo/contact-sheet.html, chọn video bằng Chọn video cục bộ, bấm Trích 3 khung, rồi dùng Tải PNG khi có kết quả. Không cần cài thư viện JavaScript hay gọi CDN.

Đường chạy đã quan sát là trang HTTP cục bộ trong trình duyệt tích hợp Codex (IAB) trên macOS 26.6.2. IAB chặn lần mở trực tiếp bằng file:// theo chính sách giao thức; vì vậy bài này không khẳng định cách bấm đúp tệp đã được kiểm chứng.

Mã xử lý tệp qua File/Blob, không có endpoint tải video lên máy chủ. Static server chỉ phục vụ trang. Điều đó không đồng nghĩa toàn bộ trình duyệt hoặc mọi tiện ích mở rộng đều không phát sinh lưu lượng khác.

Ba quyết định trước khi viết vòng lặp Chỉ nhận metadata có ích. Sau loadedmetadata, kiểm tra duration hữu hạn và lớn hơn 0, cùng videoWidth/videoHeight là số nguyên dương. Tệp không đọc được hoặc metadata không hợp lệ phải đi vào trạng thái lỗi.

Seek tuần tự trên cùng một video. Mỗi lần đặt currentTime, đợi seeked trước khi vẽ và chuyển sang mốc sau. Gán ba mốc liên tiếp rồi chụp ngay sẽ không chờ bộ giải mã hoàn thành. seeked cũng không phải chứng nhận độ chính xác từng frame hay bằng chứng về keyframe codec.

Tính kích thước từ video gốc. Mỗi ô có vùng 480 × 320. Hàm fit dùng một hệ số chung cho cả hai chiều, tối đa bằng 1, rồi căn giữa ảnh. Phần trống giữ màu nền; ảnh không bị kéo dài cho kín ô. Việc vẽ dùng kích thước nội tại của video như tài liệu drawImage mô tả.

Với fixture 5 giây, ba mốc yêu cầu là 0,5; 2,5 và 4,5 giây. Nhãn ghi cả mốc yêu cầu và currentTime quan sát được. Chúng giúp hiểu cách lấy mẫu, không đảm bảo mọi tệp khác sẽ ánh xạ tới cùng một chỉ số frame. Mã hoàn chỉnh trong một tệp Khối dưới là toàn bộ bản demo đã kiểm tra, gồm giao diện, logic và CSS. Không có phần triển khai nào phải lấy từ một đường dẫn tệp cục bộ. Có thể sao chép mã, lưu thành HTML rồi chạy bằng static server ở trên.

<!doctype html>

<html lang="vi"> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <meta http-equiv="Content-Security-Policy" content=" default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; media-src blob:; img-src blob: data:; connect-src 'none'"> <title>Ba mẫu từ video cục bộ</title> <style> * { box-sizing: border-box } body { margin: 0; background: #eef2f6; color: #17263a; font: 16px/1.6 system-ui, sans-serif } main { max-width: 1100px; margin: 32px auto; padding: 24px } section { background: white; padding: 24px; border-radius: 16px; margin-top: 20px; border: 1px solid #d7e1eb } h1 { font-size: clamp(26px, 4vw, 40px); line-height: 1.2 } h2 { font-size: 21px; margin-top: 0 } .kicker { color: #136b67; font-weight: 700 } .controls { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin: 20px 0 } button, .download { font: inherit; padding: 10px 16px; border-radius: 8px; border: 1px solid #156e68; background: #156e68; color: white; cursor: pointer } button.secondary { background: white; color: #17263a } button:disabled { opacity: .45; cursor: default } input { max-width: 100%; font: inherit } #status { border-left: 4px solid #156e68; padding: 10px 14px; background: #edf9f7; overflow-wrap: anywhere } #status[data-kind="error"] { border-color: #a32d3c; background: #fff0f1; color: #7d2030 } #meta { overflow-wrap: anywhere; color: #526175 } canvas { display: block; max-width: 100%; width: 100%; height: auto; border: 1px solid #d7e1eb } .download { display: inline-block; margin-top: 16px } [hidden] { display: none !important } small { display: block; color: #526175; margin-top: 12px } </style> <main>
HTMLVideoElement + Canvas · không CDN

Contact sheet ba mẫu, xử lý tại máy

Ba vị trí 10%, 50%, 90%. Không phải keyframe codec, không phải điểm chất lượng tự động.

<section>

1. Chọn một tệp video

<label for="file">Chọn video cục bộ</label> <input id="file" type="file" accept="video/*">
<button id="extract" disabled>Trích 3 khung</button> <button id="cancel" class="secondary">Hủy / xóa</button>

Chưa chọn video. Chưa có kết quả.

Metadata sẽ hiện sau khi đọc được video.

<small>Mã này dùng File/Blob cục bộ, không có API tải lên. Môi trường trình duyệt và tiện ích mở rộng là phạm vi khác.</small> </section> <section id="result" hidden>

2. Ba mẫu được trình duyệt giải mã

<canvas id="sheet" width="1536" height="480"></canvas> <a id="download" class="download" download="contact-sheet.png"> Tải PNG </a> <small>Giữ tỷ lệ trong từng ô; khoảng trống không bị kéo giãn. Mốc thời gian không chứng minh độ chính xác từng frame.</small> </section> </main> <script id="core"> "use strict"; function failure(code, message) { return Object.assign(new Error(message), {code}); } function validateMeta(meta) { const {duration, width, height} = meta; if (!Number.isFinite(duration) || duration <= 0 || !Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) { throw failure("META", "Metadata không có duration/kích thước hợp lệ."); } return {duration, width, height}; } function samples(duration) { if (!Number.isFinite(duration) || duration <= 0) { throw failure("META", "Duration phải hữu hạn và lớn hơn 0."); } return [0.1, 0.5, 0.9].map(fraction => ({ fraction, time: duration * fraction })); } function fit(width, height) { validateMeta({duration: 1, width, height}); const scale = Math.min(480 / width, 320 / height, 1); const w = width * scale; const h = height * scale; return {width: w, height: h, x: (480-w)/2, y: (320-h)/2}; } function makeJobs() { let active = null; function cancel() { if (active) active.dispose(); } return {cancel, begin() { cancel(); const controller = new AbortController(); let cleanups = []; let disposed = false; const job = { signal: controller.signal, current: () => active === job && !disposed, commit(fn) { if (!job.current()) return false; fn(); return true; }, addCleanup(fn) { if (disposed) fn(); else cleanups.push(fn); }, dispose() { if (disposed) return; disposed = true; controller.abort(); for (const fn of cleanups.splice(0)) { try { fn(); } catch {} } if (active === job) active = null; } }; active = job; return job; }}; } function waitFor(target, eventName, signal, timeout = 15000) { return new Promise((resolve, reject) => { if (signal.aborted) { reject(new DOMException("Đã hủy", "AbortError")); return; } let timer; const clean = () => { clearTimeout(timer); target.removeEventListener(eventName, done); target.removeEventListener("error", failed); signal.removeEventListener("abort", aborted); }; const done = () => { clean(); resolve(); }; const failed = () => { clean(); reject(failure("DECODE", "Không giải mã được video này trong trình duyệt.")); }; const aborted = () => { clean(); reject(new DOMException("Đã hủy", "AbortError")); }; target.addEventListener(eventName, done, {once: true}); target.addEventListener("error", failed, {once: true}); signal.addEventListener("abort", aborted, {once: true}); timer = setTimeout(() => { clean(); reject(failure("TIMEOUT", "Đọc/seek quá lâu; hãy thử tệp khác.")); }, timeout); }); } globalThis.ContactCore = { validateMeta, samples, fit, makeJobs, waitFor }; </script> <script id="ui"> "use strict"; const el = id => document.getElementById(id); const fileInput = el("file"); const extract = el("extract"); const result = el("result"); const status = el("status"); const meta = el("meta"); const download = el("download"); const sheet = el("sheet"); const jobs = ContactCore.makeJobs(); let selected = null; let outputURL = null; let busy = false; function say(text, kind = "info") { status.textContent = text; status.dataset.kind = kind; } function clearOutput() { result.hidden = true; download.removeAttribute("href"); if (outputURL) URL.revokeObjectURL(outputURL); outputURL = null; sheet.getContext("2d").clearRect(0, 0, sheet.width, sheet.height); } function ensure(job) { if (!job.current()) { throw new DOMException("Đã hủy", "AbortError"); } } fileInput.onchange = () => { jobs.cancel(); busy = false; clearOutput(); meta.textContent = "Metadata sẽ hiện sau khi đọc được video."; selected = fileInput.files[0] || null; if (!selected) say("Chưa chọn video. Chưa có kết quả."); else if (!selected.size) { selected = null; say("Tệp rỗng (0 byte). Hãy chọn video có dữ liệu.", "error"); } else say("Đã chọn " + selected.name + ". Nhấn Trích 3 khung."); extract.disabled = !selected; }; el("cancel").onclick = () => { const wasBusy = busy; jobs.cancel(); busy = false; selected = null; fileInput.value = ""; clearOutput(); meta.textContent = "Metadata sẽ hiện sau khi đọc được video."; extract.disabled = true; say(wasBusy ? "Đã hủy tác vụ. Không giữ kết quả cũ." : "Đã xóa kết quả. Chọn video để bắt đầu lại."); }; extract.onclick = async () => { if (!selected || !selected.size) { say("Hãy chọn tệp video không rỗng.", "error"); return; } const file = selected; const job = jobs.begin(); busy = true; clearOutput(); extract.disabled = true; say("Đang đọc metadata: " + file.name); const video = document.createElement("video"); video.preload = "auto"; video.muted = true; video.playsInline = true; const url = URL.createObjectURL(file); job.addCleanup(() => { try { video.pause(); video.removeAttribute("src"); video.load(); } finally { URL.revokeObjectURL(url); } }); try { const loaded = ContactCore.waitFor( video, "loadedmetadata", job.signal ); video.src = url; video.load(); await loaded; ensure(job); const m = ContactCore.validateMeta({ duration: video.duration, width: video.videoWidth, height: video.videoHeight }); job.commit(() => { meta.textContent = file.name + " · " + m.width + " × " + m.height + " · " + m.duration.toFixed(3) + " s"; }); if (video.readyState < 2) { await ContactCore.waitFor(video, "loadeddata", job.signal); } const work = document.createElement("canvas"); work.width = 1536; work.height = 480; const ctx = work.getContext("2d"); if (!ctx) throw new Error("Không tạo được Canvas 2D."); ctx.fillStyle = "#f4f7fb"; ctx.fillRect(0, 0, work.width, work.height); ctx.fillStyle = "#17263a"; ctx.font = "bold 24px system-ui"; ctx.fillText(file.name.slice(0, 80), 24, 34); const rect = ContactCore.fit(m.width, m.height); const points = ContactCore.samples(m.duration); for (let i = 0; i < points.length; i++) { ensure(job); const p = points[i]; job.commit(() => say( "Đang lấy mẫu " + (i+1) + "/3 tại " + p.time.toFixed(3) + " s…" )); if (Math.abs(video.currentTime - p.time) > 0.000001) { const seeked = ContactCore.waitFor( video, "seeked", job.signal ); video.currentTime = p.time; await seeked; } ensure(job); if (video.readyState < 2) { throw new Error("Chưa có dữ liệu ảnh sau seek."); } const x = 24 + i * 504; ctx.fillStyle = "#dce5ee"; ctx.fillRect(x, 64, 480, 320); ctx.drawImage(video, x + rect.x, 64 + rect.y, rect.width, rect.height); ctx.fillStyle = "#17263a"; ctx.font = "bold 20px system-ui"; ctx.fillText("Mẫu " + Math.round(p.fraction*100) + "% · yêu cầu " + p.time.toFixed(3) + " s", x, 414); ctx.font = "17px system-ui"; ctx.fillText("currentTime ≈ " + video.currentTime.toFixed(3) + " s", x, 442); } ctx.font = "17px system-ui"; ctx.fillText("Ba mẫu trình duyệt; không phải keyframe codec.", 24, 470); const blob = await new Promise((resolve, reject) => work.toBlob(b => b ? resolve(b) : reject(new Error("Không xuất được PNG.")), "image/png") ); ensure(job); const out = URL.createObjectURL(blob); if (!job.commit(() => { outputURL = out; download.href = out; sheet.width = work.width; sheet.height = work.height; sheet.getContext("2d").drawImage(work, 0, 0); result.hidden = false; say("Đã tạo 3 mẫu. Có thể tải PNG."); })) URL.revokeObjectURL(out); } catch (error) { if (error.name !== "AbortError") { job.commit(() => { clearOutput(); say(error.message || "Không xử lý được video.", "error"); }); } } finally { job.commit(() => { busy = false; extract.disabled = !selected; }); job.dispose(); } }; window.addEventListener("pagehide", () => { jobs.cancel(); clearOutput(); }); </script> </html>

Vì sao thay tệp hoặc hủy không được giữ kết quả cũ? makeJobs cấp một đối tượng cho mỗi tác vụ. Bắt đầu tác vụ mới sẽ dispose tác vụ cũ, abort các lần đợi event và chạy cleanup. Mọi lần ghi kết quả qua job.commit chỉ được chấp nhận khi đối tượng đó vẫn là tác vụ hiện tại.

Điểm này còn cần sau toBlob: xuất ảnh là bất đồng bộ. Callback cũ có thể trở về sau khi người dùng đã đổi tệp. Trước khi tạo liên kết PNG, mã kiểm tra lại tác vụ; kết quả đến muộn không được xuất bản lên giao diện mới.

Canvas làm việc được tạo riêng cho mỗi lần trích. Giao diện chỉ hiện sheet khi đủ ba mẫu và xuất được Blob. Một lần lỗi không được biến thành sheet trống nhưng mang thông báo thành công.

Tài nguyên có hai vòng đời:

URL của video được giữ trong lúc đọc/seek, rồi pause, bỏ src, gọi load() và revoke trong cleanup. URL của PNG được giữ để người dùng tải, rồi revoke khi chọn tệp khác, hủy/xóa hoặc rời trang. Không revoke nó ngay sau khi vừa gán vào liên kết tải. URL.revokeObjectURL giải phóng tham chiếu object URL. Đây là quản lý tài nguyên mà mã sở hữu, không phải một phép đo hay bảo đảm rằng bộ nhớ toàn trình duyệt đã giảm ngay.

Lỗi cần xuất hiện như lỗi accept="video/*" hỗ trợ hộp chọn tệp; nó không xác nhận nội dung có thể giải mã. Demo từ chối tệp 0 byte trước khi tạo video, bắt event error, đặt timeout cho từng lần đợi và từ chối metadata vô hạn hoặc thiếu kích thước.

Hàm waitFor gỡ listener và timer ở cả bốn đường: event thành công, lỗi, timeout và abort. Trong vòng seek, nếu chưa có dữ liệu ảnh (readyState < 2), mã báo lỗi thay vì vẽ một ô không có frame.

Để tự tạo hai tệp lỗi tối thiểu bằng Python, có thể dùng:

from pathlib import Path Path("empty.mp4").write_bytes(b"") Path("decode-failure.mp4").write_text( "Not a video", encoding="utf-8" ) Chọn empty.mp4 để xem lỗi 0 byte. Chọn decode-failure.mp4 rồi bấm trích để kiểm tra đường lỗi bộ giải mã. Tên đuôi .mp4 không làm các byte văn bản trở thành video. Những gì đã kiểm tra, và phạm vi của kết quả Fixture hợp lệ là hai clip nguyên gốc do script Python/Pillow và FFmpeg tạo: một clip 640 × 360, một clip 360 × 640, đều 5 giây, 30 fps, không âm thanh. Hình vuông xanh ngọc chuyển vị trí trên nền lưới và có chữ SYNTHETIC / LOCAL. H.264 là cấu hình của fixture, không phải lời hứa về codec từ nhà cung cấp video AI.

Qua IAB và HTTP loopback trên macOS, đã quan sát:

Video ngang được đọc là 640 × 360, 5 giây; ba mẫu được tạo và PNG thực sự được tải về. Video dọc được đọc là 360 × 640, 5 giây; PNG tải về giữ tỷ lệ, có khoảng trống hai bên ảnh dọc. Bấm Hủy / xóa sau khi hoàn tất xóa kết quả hiển thị và liên kết tải. Tệp rỗng hiện lỗi 0 byte. Tệp văn bản giả video hiện lỗi giải mã, không còn sheet hay liên kết tải cũ. Bộ kiểm thử logic bằng Node 24.19.0 có 7 test vượt qua sau chu kỳ đỏ–xanh: metadata, mốc lấy mẫu, tỷ lệ, commit đến muộn, revoke Blob URL thực tế, và cleanup khi event thành công/lỗi/timeout/abort. Việc hủy khi đang trích và thay tệp rất nhanh thuộc kiểm thử logic xác định này; hai tình huống đó chưa được chủ động chèn bằng thao tác browser trong lần quan sát trên.

Kết quả chỉ xác nhận bản demo và các fixture trong môi trường đã nêu. Codec khác, video hỏng một phần, thời lượng rất lớn hoặc một trình duyệt khác cần được kiểm tra riêng. Ba ảnh tĩnh cũng có thể bỏ qua lỗi xảy ra giữa các mốc; hãy xem video khi cần đánh giá chuyển động.

Nguồn clip tùy chọn: tạo thủ công rồi tải về Nếu tìm theo cụm “free minimax h3 video generator no signup required”, trang chính thức của Max Maker AI mô tả dịch vụ miễn phí, không cần đăng ký, gắn nhãn unlimited và powered by MiniMax H3, với clip 5 giây kèm âm thanh AI. Trang cũng nêu xử lý một video mỗi lần, bước xác minh khi được yêu cầu, hàng đợi và clip hoàn tất trong trình duyệt hiện tại; unlimited không có nghĩa nhiều yêu cầu đồng thời hoặc không phải chờ. Đây là mô tả của dịch vụ, không phải kết quả thử tạo video trong bài này.

Gợi ý công cụ: Max Maker AI là một lựa chọn để tạo clip ý tưởng rồi tải về trước khi xem lại bằng tiện ích cục bộ này.

Ở tuyến văn bản, hai ô ảnh bắt đầu/kết thúc được để trống; prompts được nhập thủ công tại dịch vụ. Sau khi tạo và tải clip bằng thao tác của mình, người dùng có thể chọn tệp đó trong tiện ích. Mã ở đây không gọi API Max Maker AI, không tự vận hành website và không đảm bảo codec hay quyền sử dụng của một clip được tải về.

Tiện ích vẫn dùng được với video cục bộ khác mà trình duyệt giải mã được. Giữ ý nghĩa của đầu ra rõ ràng: ba mẫu có nhãn để xem lại, không phải chứng nhận chất lượng của cả video.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí