1292 lines
50 KiB
HTML
1292 lines
50 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Reel</title>
|
|
<style>
|
|
* { box-sizing: border-box; border-radius: 0 !important; }
|
|
html, body { background: #fff; color: #000; }
|
|
body {
|
|
margin: 0;
|
|
font-family: "Segoe UI", Arial, sans-serif;
|
|
font-size: 16px;
|
|
line-height: 1.45;
|
|
}
|
|
.wrap { max-width: 860px; margin: 0 auto; padding: 28px 20px 60px; }
|
|
h1 { font-size: 34px; margin: 0 0 4px; letter-spacing: 1px; }
|
|
.sub { margin: 0 0 26px; color: #333; }
|
|
section {
|
|
border: 2px solid #000;
|
|
padding: 18px 20px 20px;
|
|
margin-bottom: 22px;
|
|
}
|
|
h2 { font-size: 21px; margin: 0 0 4px; }
|
|
h2 .num {
|
|
display: inline-block; width: 30px; height: 30px; line-height: 28px;
|
|
text-align: center; border: 2px solid #000; margin-right: 10px; font-size: 17px;
|
|
}
|
|
.hint { color: #444; font-size: 14px; margin: 2px 0 12px; }
|
|
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
|
select {
|
|
font: inherit; color: #000; background: #fff; border: 2px solid #000; padding: 8px 10px; cursor: pointer;
|
|
}
|
|
.gens { margin: 6px 0 0; padding-left: 20px; }
|
|
.gens li { margin: 4px 0; }
|
|
.gens a { color: #000; font-weight: 700; }
|
|
.gens a:hover { background: #000; color: #fff; }
|
|
input[type=text], textarea {
|
|
font: inherit; color: #000; background: #fff;
|
|
border: 2px solid #000; padding: 9px 10px;
|
|
}
|
|
input[type=text] { flex: 1 1 320px; min-width: 0; }
|
|
textarea { width: 100%; resize: vertical; height: 160px; }
|
|
textarea:focus, input:focus, button:focus-visible { outline: 3px solid #ff69b4; outline-offset: 1px; }
|
|
input[type=file] { display: none; }
|
|
button {
|
|
font: inherit; font-weight: 600; cursor: pointer;
|
|
background: #fff; color: #000;
|
|
border: 2px solid #000; padding: 9px 16px;
|
|
}
|
|
button:hover:not(:disabled) { background: #000; color: #fff; }
|
|
button.primary { background: #000; color: #fff; font-size: 19px; padding: 13px 26px; }
|
|
button.primary:hover:not(:disabled) { background: #fff; color: #000; }
|
|
button:disabled { opacity: .35; cursor: not-allowed; }
|
|
label.check { display: flex; gap: 10px; align-items: center; cursor: pointer; margin-top: 10px; font-size: 17px; }
|
|
label.check input[type=checkbox] { width: 20px; height: 20px; accent-color: #000; margin: 0; }
|
|
label.check input[type=radio] {
|
|
appearance: none; -webkit-appearance: none; margin: 0;
|
|
width: 20px; height: 20px; border: 2px solid #000; background: #fff; cursor: pointer; flex: none;
|
|
}
|
|
label.check input[type=radio]:checked { background: #000; box-shadow: inset 0 0 0 3px #fff; }
|
|
.info { margin-top: 10px; font-weight: 600; min-height: 22px; }
|
|
.drop {
|
|
border: 2px dashed #000; padding: 22px; text-align: center; cursor: pointer; margin-bottom: 12px;
|
|
}
|
|
.drop.over { background: #000; color: #fff; }
|
|
.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
|
.thumb { border: 2px solid #000; display: flex; flex-direction: column; }
|
|
.thumb .pic { position: relative; height: 100px; background: #eee; }
|
|
.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
.thumb .n {
|
|
position: absolute; top: 0; left: 0; background: #000; color: #fff;
|
|
font-weight: 700; padding: 1px 8px; font-size: 14px;
|
|
}
|
|
.thumb .btns { display: flex; border-top: 2px solid #000; }
|
|
.thumb .btns button { flex: 1; border: none; padding: 5px 0; font-size: 15px; }
|
|
.thumb .btns button + button { border-left: 2px solid #000; }
|
|
.note { margin-top: 10px; font-size: 14px; color: #444; }
|
|
.warn { margin-top: 10px; font-size: 14px; font-weight: 600; }
|
|
.bar { height: 22px; border: 2px solid #000; margin-top: 14px; position: relative; }
|
|
.bar > div { height: 100%; width: 0; background: #000; transition: width .2s; }
|
|
#status { margin-top: 10px; min-height: 24px; font-weight: 600; }
|
|
#status.err { color: #b00000; }
|
|
.screen { margin-top: 14px; background: #111; display: flex; justify-content: center; border: 2px solid #000; }
|
|
#view { display: block; max-width: 100%; max-height: 420px; }
|
|
video { width: 100%; max-height: 520px; margin-top: 14px; background: #111; }
|
|
details { border: 2px solid #000; padding: 12px 16px; margin-top: 12px; }
|
|
summary { cursor: pointer; font-weight: 700; }
|
|
.projbar { border: 2px solid #000; padding: 12px 16px; margin-bottom: 22px; }
|
|
.resume { background: #000; color: #fff; padding: 14px 16px; margin-bottom: 22px; }
|
|
.resume button { border-color: #fff; background: #000; color: #fff; }
|
|
.resume button:hover:not(:disabled) { background: #fff; color: #000; }
|
|
.hidden { display: none !important; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<h1>Reel</h1>
|
|
<p class="sub">Turns a Narrator mp3 and a few pictures into a video for YouTube or TikTok. Free, no account, and everything runs on this computer.</p>
|
|
|
|
<div class="resume hidden" id="resumeBox">
|
|
<div id="resumeText" style="font-weight:700;margin-bottom:10px"></div>
|
|
<div class="row">
|
|
<button id="resumeBtn">Pick up where I left off</button>
|
|
<button id="freshBtn">Start fresh</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="projbar">
|
|
<div class="row">
|
|
<button id="saveProjBtn">Save project</button>
|
|
<button id="openProjBtn">Open project</button>
|
|
<input type="file" id="projFile" accept=".reel,application/json">
|
|
<button id="newProjBtn">New project</button>
|
|
</div>
|
|
<p class="hint" style="margin:8px 0 0">Your work also saves itself in this browser as you go, so you can close the page and come back. Save project makes a file you can keep for each video and open again later.</p>
|
|
</div>
|
|
|
|
<section>
|
|
<h2><span class="num">1</span>Pick the audio</h2>
|
|
<p class="hint">Choose the mp3 you made in Narrator. The video will be exactly as long as the audio.</p>
|
|
<div class="row">
|
|
<button id="audioBtn">Choose MP3</button>
|
|
<input type="file" id="audioFile" accept=".mp3,audio/*">
|
|
</div>
|
|
<div class="info" id="audioInfo"></div>
|
|
<p class="hint" style="margin-top:14px">Video title. It shows on the opening screen for the first few seconds.</p>
|
|
<div class="row">
|
|
<input type="text" id="title" placeholder="Example: Bodycam Foot Fetish">
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2><span class="num">2</span>Add pictures</h2>
|
|
<p class="hint">They play in this order. Use the arrows to move one, X to remove it.</p>
|
|
<div class="drop" id="drop">Drag pictures here, or click to choose them</div>
|
|
<input type="file" id="imgFile" accept="image/*" multiple>
|
|
<div class="thumbs" id="thumbs"></div>
|
|
<div class="note" id="imgInfo">No pictures yet.</div>
|
|
<div class="row" style="margin-top:14px">
|
|
<strong>Change pictures every:</strong>
|
|
<select id="every">
|
|
<option value="0">Spread evenly (each picture shows once)</option>
|
|
<option value="3">3 seconds</option>
|
|
<option value="4">4 seconds</option>
|
|
<option value="5" selected>5 seconds</option>
|
|
<option value="8">8 seconds</option>
|
|
<option value="10">10 seconds</option>
|
|
<option value="15">15 seconds</option>
|
|
<option value="20">20 seconds</option>
|
|
</select>
|
|
</div>
|
|
<p class="hint" style="margin-top:8px">When pictures run out, they come back around as close-ups and slow pans on a different part of the picture, so the same picture never looks the same twice in a row.</p>
|
|
<div class="row" style="margin-top:14px">
|
|
<strong>Style:</strong>
|
|
<select id="style">
|
|
<option value="comic" selected>Motion comic (punch-ins, slides, shakes)</option>
|
|
<option value="calm">Calm (slow zoom and fade)</option>
|
|
</select>
|
|
</div>
|
|
<p class="hint" style="margin-top:8px">Motion comic splits each picture into a few shots: the whole picture first, then quick punch-ins on different parts with a little jolt. Pictures change with slides, white flashes, zoom-throughs and hard cuts. Calm is the original slow zoom and fade.</p>
|
|
<label class="check"><input type="checkbox" id="panels" checked> Comic panels now and then (2 or 3 pictures on screen at once)</label>
|
|
<label class="check"><input type="checkbox" id="texture"> Comic print look (ink dots and dark edges)</label>
|
|
<details>
|
|
<summary>Need more pictures? Free image makers</summary>
|
|
<p class="hint">Opens in a new tab. Paste the same prompt into a few of these to spread out the daily limits. The ones that take a reference picture can use your soyjak cutout so he looks the same everywhere.</p>
|
|
<ul class="gens">
|
|
<li><a href="https://grok.com/imagine" target="_blank" rel="noopener">Grok Imagine</a>, the usual one</li>
|
|
<li><a href="https://www.bing.com/images/create" target="_blank" rel="noopener">Bing Image Creator</a>, free with a Microsoft account</li>
|
|
<li><a href="https://gemini.google.com" target="_blank" rel="noopener">Google Gemini</a>, takes a reference picture</li>
|
|
<li><a href="https://labs.google/fx/tools/image-fx" target="_blank" rel="noopener">Google ImageFX</a></li>
|
|
<li><a href="https://www.meta.ai" target="_blank" rel="noopener">Meta AI</a></li>
|
|
<li><a href="https://ideogram.ai" target="_blank" rel="noopener">Ideogram</a>, good with words inside pictures</li>
|
|
<li><a href="https://leonardo.ai" target="_blank" rel="noopener">Leonardo</a>, daily free credits</li>
|
|
<li><a href="https://www.craiyon.com" target="_blank" rel="noopener">Craiyon</a>, no account needed, rougher looking</li>
|
|
</ul>
|
|
</details>
|
|
</section>
|
|
|
|
<section>
|
|
<h2><span class="num">3</span>Pick the shape</h2>
|
|
<label class="check"><input type="radio" name="shape" value="h" checked> For YouTube (horizontal)</label>
|
|
<label class="check"><input type="radio" name="shape" value="v"> For TikTok (vertical)</label>
|
|
<div class="warn" id="shapeWarn"></div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2><span class="num">4</span>Subtitles</h2>
|
|
<label class="check"><input type="checkbox" id="subsOn" checked> Show subtitles</label>
|
|
<div id="subsBox">
|
|
<p class="hint" style="margin-top:12px">In Narrator, after making the mp3, click "Save subtitles (for Reel)". Then choose that file here so the words line up exactly with the voice.</p>
|
|
<div class="row">
|
|
<button id="srtBtn">Choose subtitle file</button>
|
|
<input type="file" id="srtFile" accept=".srt,text/plain">
|
|
</div>
|
|
<div class="info" id="srtInfo"></div>
|
|
<details>
|
|
<summary>No subtitle file? Paste the text instead</summary>
|
|
<p class="hint">Paste the same text you gave Narrator. The timing gets spread out evenly, so it will be close but can drift a little on long videos.</p>
|
|
<textarea id="subText"></textarea>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2><span class="num">5</span>Make the video</h2>
|
|
<p class="hint">This takes a while for long articles. Leave this window open while it works. You can watch it before you save. Play 20 second preview shows the movement right here without making anything.</p>
|
|
<div class="row">
|
|
<input type="text" id="fname" placeholder="File name">
|
|
<span>.mp4</span>
|
|
</div>
|
|
<div class="screen"><canvas id="view" width="640" height="360"></canvas></div>
|
|
<div class="row" style="margin-top:14px">
|
|
<button class="primary" id="makeBtn">Make Video</button>
|
|
<button id="pvBtn">Play 20 second preview</button>
|
|
<button id="stopBtn" class="hidden">Stop</button>
|
|
</div>
|
|
<div class="bar"><div id="barFill"></div></div>
|
|
<div id="status">Ready.</div>
|
|
<video id="player" controls class="hidden"></video>
|
|
<div class="row" style="margin-top:12px">
|
|
<button id="saveBtn" class="hidden">Save MP4</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<script type="module">
|
|
import { Muxer, ArrayBufferTarget } from "https://cdn.jsdelivr.net/npm/mp4-muxer@5.2.2/build/mp4-muxer.mjs";
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const FPS = 30;
|
|
const RATE = 48000;
|
|
const PINK = "#ff69b4";
|
|
const FONT = "'Segoe UI Black', 'Segoe UI', Arial, sans-serif";
|
|
|
|
let audio = null; // Float32Array, mono, 48 kHz
|
|
let audioFile = null; // the original mp3, kept for saving the project
|
|
let srtName = "", srtText = "";
|
|
let restoring = false, saveTimer = null;
|
|
let duration = 0; // seconds
|
|
let images = []; // { name, bmp, w, h, thumb, bg: {} }
|
|
let cues = []; // { a, b, text } from the subtitle file
|
|
let busy = false, stopFlag = false, outUrl = null, outName = "";
|
|
|
|
const tick = () => new Promise(r => setTimeout(r, 0));
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
const lerp = (a, b, t) => a + (b - a) * t;
|
|
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
|
|
|
|
function status(msg, isErr = false) {
|
|
$("status").textContent = msg;
|
|
$("status").classList.toggle("err", isErr);
|
|
}
|
|
function bar(frac) { $("barFill").style.width = Math.round(clamp(frac, 0, 1) * 100) + "%"; }
|
|
function fmtTime(sec) {
|
|
sec = Math.max(0, Math.round(sec));
|
|
const m = Math.floor(sec / 60), s = sec % 60;
|
|
return m ? `${m}:${String(s).padStart(2, "0")}` : `${s} seconds`;
|
|
}
|
|
function safeName(s) {
|
|
return s.trim().replace(/[\\/:*?"<>|]+/g, "").replace(/\s+/g, "_").slice(0, 120);
|
|
}
|
|
function shape() { return document.querySelector("input[name=shape]:checked").value; }
|
|
function size() { return shape() === "v" ? [1080, 1920] : [1920, 1080]; }
|
|
|
|
// ---------- Audio ----------
|
|
$("audioBtn").addEventListener("click", () => $("audioFile").click());
|
|
$("audioFile").addEventListener("change", () => { const f = $("audioFile").files[0]; if (f) loadAudio(f); });
|
|
async function loadAudio(f, keepTitle = false) {
|
|
$("audioInfo").textContent = "Reading the mp3...";
|
|
try {
|
|
const ac = new AudioContext({ sampleRate: RATE });
|
|
const buf = await ac.decodeAudioData(await f.arrayBuffer());
|
|
audioFile = f;
|
|
ac.close();
|
|
const mono = new Float32Array(buf.length);
|
|
for (let c = 0; c < buf.numberOfChannels; c++) {
|
|
const ch = buf.getChannelData(c);
|
|
for (let i = 0; i < ch.length; i++) mono[i] += ch[i] / buf.numberOfChannels;
|
|
}
|
|
audio = mono;
|
|
duration = buf.length / RATE;
|
|
$("audioInfo").textContent = `${f.name}, ${fmtTime(duration)} long.`;
|
|
const guess = f.name.replace(/\.[^.]+$/, "").replace(/_text_to_speech$/i, "").replace(/_/g, " ").trim();
|
|
if (!$("title").value.trim()) $("title").value = guess;
|
|
if (!keepTitle) autoName(true);
|
|
refresh();
|
|
} catch (e) {
|
|
console.error(e);
|
|
audio = null; duration = 0; audioFile = null;
|
|
$("audioInfo").textContent = "";
|
|
status("Couldn't read that audio file. Try the mp3 straight from Narrator.", true);
|
|
}
|
|
}
|
|
$("title").addEventListener("input", () => { autoName(); drawStill(); });
|
|
$("fname").addEventListener("input", () => scheduleSave());
|
|
|
|
function autoName(force = false) {
|
|
const t = $("title").value.trim();
|
|
if (!t) return;
|
|
const cur = $("fname").value.trim();
|
|
if (force || !cur || /_(youtube|tiktok)$/.test(cur)) {
|
|
$("fname").value = safeName(t) + (shape() === "v" ? "_tiktok" : "_youtube");
|
|
}
|
|
}
|
|
|
|
// ---------- Pictures ----------
|
|
$("drop").addEventListener("click", () => $("imgFile").click());
|
|
$("imgFile").addEventListener("change", () => { addImages($("imgFile").files); $("imgFile").value = ""; });
|
|
$("drop").addEventListener("dragover", (e) => { e.preventDefault(); $("drop").classList.add("over"); });
|
|
$("drop").addEventListener("dragleave", () => $("drop").classList.remove("over"));
|
|
$("drop").addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
$("drop").classList.remove("over");
|
|
addImages(e.dataTransfer.files);
|
|
});
|
|
|
|
async function addImages(files) {
|
|
const list = [...files].filter(f => f.type.startsWith("image/"));
|
|
if (!list.length) return;
|
|
status(`Loading ${list.length} picture${list.length > 1 ? "s" : ""}...`);
|
|
for (const f of list) {
|
|
try {
|
|
let bmp = await createImageBitmap(f);
|
|
const big = Math.max(bmp.width, bmp.height);
|
|
if (big > 2560) {
|
|
const k = 2560 / big;
|
|
const c = document.createElement("canvas");
|
|
c.width = Math.round(bmp.width * k); c.height = Math.round(bmp.height * k);
|
|
const x = c.getContext("2d");
|
|
x.imageSmoothingQuality = "high";
|
|
x.drawImage(bmp, 0, 0, c.width, c.height);
|
|
bmp.close();
|
|
bmp = await createImageBitmap(c);
|
|
}
|
|
images.push({ name: f.name, file: f, bmp, w: bmp.width, h: bmp.height, thumb: URL.createObjectURL(f), bg: {} });
|
|
} catch (e) {
|
|
console.error(e);
|
|
status(`Couldn't open ${f.name}. Skipped it.`, true);
|
|
}
|
|
}
|
|
drawThumbs();
|
|
refresh();
|
|
status("Ready.");
|
|
}
|
|
|
|
function drawThumbs() {
|
|
const box = $("thumbs");
|
|
box.innerHTML = "";
|
|
images.forEach((im, i) => {
|
|
const d = document.createElement("div");
|
|
d.className = "thumb";
|
|
d.innerHTML = `<div class="pic"><img alt=""><span class="n">${i + 1}</span></div>
|
|
<div class="btns"><button title="Move earlier">◀</button><button title="Move later">▶</button><button title="Remove">X</button></div>`;
|
|
d.querySelector("img").src = im.thumb;
|
|
const [left, right, del] = d.querySelectorAll("button");
|
|
left.disabled = i === 0 || busy;
|
|
right.disabled = i === images.length - 1 || busy;
|
|
del.disabled = busy;
|
|
left.onclick = () => { [images[i - 1], images[i]] = [images[i], images[i - 1]]; drawThumbs(); drawStill(); };
|
|
right.onclick = () => { [images[i + 1], images[i]] = [images[i], images[i + 1]]; drawThumbs(); drawStill(); };
|
|
del.onclick = () => {
|
|
URL.revokeObjectURL(im.thumb);
|
|
im.bmp.close();
|
|
images.splice(i, 1);
|
|
drawThumbs();
|
|
refresh();
|
|
};
|
|
box.appendChild(d);
|
|
});
|
|
let txt = images.length ? `${images.length} picture${images.length > 1 ? "s" : ""}.` : "No pictures yet.";
|
|
if (images.length && duration) {
|
|
const D = slotLen();
|
|
const slots = Math.ceil(duration / D - 0.001);
|
|
if (slots > images.length) txt += ` A new shot every ${fmtTime(D)}, ${slots} shots in all, so the pictures come back around as close-ups.`;
|
|
else txt += ` Each one shows for about ${fmtTime(D)}.`;
|
|
}
|
|
$("imgInfo").textContent = txt;
|
|
}
|
|
|
|
// ---------- Shape ----------
|
|
document.querySelectorAll("input[name=shape]").forEach(r => r.addEventListener("change", () => { autoName(); refresh(); }));
|
|
$("every").addEventListener("change", () => refresh());
|
|
["style", "panels", "texture"].forEach(id => $(id).addEventListener("change", () => refresh()));
|
|
|
|
function shapeWarning() {
|
|
let w = "";
|
|
if (shape() === "v" && duration) {
|
|
if (duration > 180) w = `Heads up: this one runs ${fmtTime(duration)}. Instagram only pushes Reels under 3 minutes. TikTok is fine with it.`;
|
|
else if (duration < 60) w = "Heads up: TikTok only pays out on videos longer than 1 minute.";
|
|
}
|
|
$("shapeWarn").textContent = w;
|
|
}
|
|
|
|
// ---------- Subtitles ----------
|
|
$("subsOn").addEventListener("change", () => {
|
|
$("subsBox").classList.toggle("hidden", !$("subsOn").checked);
|
|
drawStill();
|
|
});
|
|
$("srtBtn").addEventListener("click", () => $("srtFile").click());
|
|
$("srtFile").addEventListener("change", async () => {
|
|
const f = $("srtFile").files[0];
|
|
if (!f) return;
|
|
setSrt(f.name, await f.text());
|
|
});
|
|
$("subText").addEventListener("input", () => drawStill());
|
|
|
|
function setSrt(name, text) {
|
|
srtName = name; srtText = text;
|
|
cues = text ? parseSrt(text) : [];
|
|
$("srtInfo").textContent = !text ? "" : cues.length ? `${name}, ${cues.length} lines.` : "That file had no subtitles in it.";
|
|
drawStill();
|
|
}
|
|
|
|
function parseSrt(txt) {
|
|
const toSec = (h, m, s, ms) => (+h) * 3600 + (+m) * 60 + (+s) + (+ms) / 1000;
|
|
const out = [];
|
|
for (const block of txt.replace(/\r/g, "").split(/\n\s*\n/)) {
|
|
const m = block.match(/(\d+):(\d+):(\d+)[,.](\d+)\s*-->\s*(\d+):(\d+):(\d+)[,.](\d+)\s*\n([\s\S]*)/);
|
|
if (!m) continue;
|
|
const text = m[9].replace(/\n/g, " ").replace(/\s+/g, " ").trim();
|
|
if (text) out.push({ a: toSec(m[1], m[2], m[3], m[4]), b: toSec(m[5], m[6], m[7], m[8]), text });
|
|
}
|
|
return out.sort((x, y) => x.a - y.a);
|
|
}
|
|
|
|
// Pasted text: spread the sentences across the whole audio by length
|
|
function cuesFromText(txt) {
|
|
const sents = [];
|
|
for (const para of txt.replace(/\r/g, "").split(/\n+/)) {
|
|
const p = para.trim();
|
|
if (!p || /^(\*\s*){3}$|^-{3,}$/.test(p)) continue;
|
|
for (const s of (p.match(/[^.!?]+(?:[.!?]+["”')\]]*|$)/g) || [p])) if (s.trim()) sents.push(s.trim());
|
|
}
|
|
const total = sents.reduce((n, s) => n + s.length + 12, 0) || 1;
|
|
let t = 0;
|
|
return sents.map(s => {
|
|
const d = duration * (s.length + 12) / total;
|
|
const c = { a: t, b: t + d, text: s };
|
|
t += d;
|
|
return c;
|
|
});
|
|
}
|
|
|
|
// ---------- Drawing ----------
|
|
const MOVES = [
|
|
{ z: [1.00, 1.14], p: [[0.5, 0.5], [0.5, 0.45]] },
|
|
{ z: [1.14, 1.00], p: [[0.3, 0.4], [0.6, 0.55]] },
|
|
{ z: [1.04, 1.16], p: [[0.7, 0.35], [0.4, 0.6]] },
|
|
{ z: [1.16, 1.04], p: [[0.45, 0.7], [0.6, 0.3]] },
|
|
];
|
|
|
|
function blurredBg(im, W, H) {
|
|
const key = W + "x" + H;
|
|
if (im.bg[key]) return im.bg[key];
|
|
const c = document.createElement("canvas");
|
|
c.width = Math.round(W / 8); c.height = Math.round(H / 8);
|
|
const x = c.getContext("2d");
|
|
const s = Math.max(c.width / im.w, c.height / im.h) * 1.1;
|
|
x.filter = "blur(3px)";
|
|
x.drawImage(im.bmp, (c.width - im.w * s) / 2, (c.height - im.h * s) / 2, im.w * s, im.h * s);
|
|
x.filter = "none";
|
|
x.fillStyle = "rgba(0,0,0,0.45)";
|
|
x.fillRect(0, 0, c.width, c.height);
|
|
im.bg[key] = c;
|
|
return c;
|
|
}
|
|
|
|
// Close-ups and pans used when a picture comes back around
|
|
const CLOSE = [
|
|
{ z: [1.55, 1.70], p: [[0.30, 0.30], [0.36, 0.36]] },
|
|
{ z: [1.70, 1.50], p: [[0.70, 0.62], [0.64, 0.55]] },
|
|
{ z: [1.35, 1.40], p: [[0.08, 0.50], [0.92, 0.50]] },
|
|
{ z: [1.45, 1.60], p: [[0.22, 0.72], [0.30, 0.65]] },
|
|
{ z: [1.60, 1.45], p: [[0.78, 0.25], [0.70, 0.35]] },
|
|
{ z: [1.35, 1.40], p: [[0.50, 0.10], [0.50, 0.90]] },
|
|
];
|
|
|
|
function slotLen() {
|
|
const n = Math.max(1, images.length);
|
|
const even = duration / n;
|
|
const every = Number($("every").value);
|
|
return every > 0 ? Math.min(every, even) : even;
|
|
}
|
|
|
|
function drawSlot(ctx, k, prog, W, H) {
|
|
const n = images.length;
|
|
const idx = k % n, visit = Math.floor(k / n);
|
|
const mv = visit === 0 ? MOVES[idx % MOVES.length] : CLOSE[(idx * 2 + visit - 1) % CLOSE.length];
|
|
drawPicture(ctx, images[idx], prog, mv, visit > 0, W, H);
|
|
}
|
|
|
|
function drawPicture(ctx, im, prog, mv, closeUp, W, H) {
|
|
const t = clamp(prog, 0, 1.3);
|
|
let z = lerp(mv.z[0], mv.z[1], t);
|
|
const px = lerp(mv.p[0][0], mv.p[1][0], t);
|
|
const py = lerp(mv.p[0][1], mv.p[1][1], t);
|
|
const ar = im.w / im.h, fr = W / H;
|
|
const badFit = Math.max(ar / fr, fr / ar) > 1.45;
|
|
const mismatch = !closeUp && badFit;
|
|
// a wide picture in a tall video is already cropped hard, so zoom in less
|
|
if (closeUp && badFit) z = 1 + (z - 1) * 0.3;
|
|
if (!mismatch) {
|
|
const s = Math.max(W / im.w, H / im.h) * z;
|
|
const dw = im.w * s, dh = im.h * s;
|
|
ctx.drawImage(im.bmp, -(dw - W) * px, -(dh - H) * py, dw, dh);
|
|
} else {
|
|
// Picture doesn't fit the shape: blurred copy behind, whole picture on top
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.drawImage(blurredBg(im, W, H), 0, 0, W, H);
|
|
const s = Math.min(W * 0.94 / im.w, H * 0.94 / im.h) * (1 + (z - 1) * 0.5);
|
|
const dw = im.w * s, dh = im.h * s;
|
|
ctx.drawImage(im.bmp, (W - dw) / 2, (H - dh) / 2, dw, dh);
|
|
}
|
|
}
|
|
|
|
function wrapLines(ctx, text, maxW) {
|
|
const words = text.split(/\s+/);
|
|
const lines = [];
|
|
let line = "";
|
|
for (const w of words) {
|
|
const test = line ? line + " " + w : w;
|
|
if (ctx.measureText(test).width > maxW && line) { lines.push(line); line = w; }
|
|
else line = test;
|
|
}
|
|
if (line) lines.push(line);
|
|
return lines;
|
|
}
|
|
|
|
function titleTime() { return Math.min(4, duration * 0.3); }
|
|
|
|
function drawTitle(ctx, t, W, H) {
|
|
const T = titleTime();
|
|
if (t >= T) return;
|
|
const a = t < T - 0.7 ? 1 : (T - t) / 0.7;
|
|
ctx.fillStyle = `rgba(0,0,0,${0.62 * a})`;
|
|
ctx.fillRect(0, 0, W, H);
|
|
const title = $("title").value.trim();
|
|
if (!title) return;
|
|
const fs = W > H ? 96 : 92;
|
|
ctx.save();
|
|
ctx.globalAlpha = a;
|
|
ctx.font = `900 ${fs}px ${FONT}`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
const lines = wrapLines(ctx, title, W * 0.84).slice(0, 5);
|
|
const lh = fs * 1.15;
|
|
const top = H / 2 - (lines.length - 1) * lh / 2;
|
|
ctx.fillStyle = "#fff";
|
|
lines.forEach((l, i) => ctx.fillText(l, W / 2, top + i * lh));
|
|
ctx.restore();
|
|
}
|
|
|
|
// Breaks each subtitle into pieces that fit on two lines, timed by length
|
|
function buildCaptions(ctx, W, H) {
|
|
if (!$("subsOn").checked) return [];
|
|
let src = cues;
|
|
if (!src.length && $("subText").value.trim() && duration) src = cuesFromText($("subText").value);
|
|
if (!src.length) return [];
|
|
const fs = W > H ? 58 : 66;
|
|
ctx.font = `900 ${fs}px ${FONT}`;
|
|
const maxW = W * 0.86;
|
|
const out = [];
|
|
const split = (c) => {
|
|
if (wrapLines(ctx, c.text, maxW).length <= 2) { out.push(c); return; }
|
|
const words = c.text.split(/\s+/);
|
|
if (words.length < 2) { out.push(c); return; }
|
|
const half = Math.ceil(words.length / 2);
|
|
const t1 = words.slice(0, half).join(" "), t2 = words.slice(half).join(" ");
|
|
const mid = c.a + (c.b - c.a) * t1.length / (t1.length + t2.length);
|
|
split({ a: c.a, b: mid, text: t1 });
|
|
split({ a: mid, b: c.b, text: t2 });
|
|
};
|
|
src.forEach(split);
|
|
return out;
|
|
}
|
|
|
|
function drawCaption(ctx, caps, t, W, H) {
|
|
if (!caps.length || t < titleTime() - 0.2) return;
|
|
let c = null;
|
|
for (let lo = 0, hi = caps.length - 1; lo <= hi;) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (caps[mid].b <= t) lo = mid + 1;
|
|
else if (caps[mid].a > t) hi = mid - 1;
|
|
else { c = caps[mid]; break; }
|
|
}
|
|
if (!c) return;
|
|
const fs = W > H ? 58 : 66;
|
|
ctx.save();
|
|
ctx.font = `900 ${fs}px ${FONT}`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.lineJoin = "round";
|
|
ctx.lineWidth = fs * 0.2;
|
|
ctx.strokeStyle = "#000";
|
|
ctx.fillStyle = "#fff";
|
|
const lines = wrapLines(ctx, c.text, W * 0.86).slice(0, 3);
|
|
const lh = fs * 1.18;
|
|
const bottom = W > H ? H - 90 : H * 0.72;
|
|
lines.forEach((l, i) => {
|
|
const y = bottom - (lines.length - 1 - i) * lh;
|
|
ctx.strokeText(l, W / 2, y);
|
|
ctx.fillText(l, W / 2, y);
|
|
});
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawFrame(ctx, t, W, H, caps) {
|
|
ctx.fillStyle = "#000";
|
|
ctx.fillRect(0, 0, W, H);
|
|
const n = images.length;
|
|
if (n && comic()) {
|
|
drawFrameComic(ctx, t, W, H);
|
|
} else if (n) {
|
|
const D = slotLen();
|
|
const last = Math.max(0, Math.ceil(duration / D - 0.001) - 1);
|
|
const k = Math.min(last, Math.floor(t / D));
|
|
const FADE = Math.min(0.8, D * 0.25);
|
|
const into = t - k * D;
|
|
if (k > 0 && into < FADE) {
|
|
drawSlot(ctx, k - 1, (t - (k - 1) * D) / D, W, H);
|
|
ctx.globalAlpha = into / FADE;
|
|
drawSlot(ctx, k, into / D, W, H);
|
|
ctx.globalAlpha = 1;
|
|
} else {
|
|
drawSlot(ctx, k, into / D, W, H);
|
|
}
|
|
}
|
|
drawTexture(ctx, W, H);
|
|
drawTitle(ctx, t, W, H);
|
|
drawCaption(ctx, caps, t, W, H);
|
|
}
|
|
|
|
// ---------- Motion comic ----------
|
|
const comic = () => $("style").value === "comic";
|
|
|
|
// Same numbers every time for the same shot, so the preview matches the video
|
|
function rnd(a, b = 0) {
|
|
let h = Math.imul(a + 1, 374761393) ^ Math.imul(b + 7, 668265263);
|
|
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
|
h ^= h >>> 16;
|
|
return (h >>> 0) / 4294967296;
|
|
}
|
|
const easeOut = (t) => 1 - Math.pow(1 - clamp(t, 0, 1), 3);
|
|
const easeBack = (t) => { t = clamp(t, 0, 1); const c = 1.4; return 1 + (c + 1) * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2); };
|
|
|
|
function shakeAt(dt, W, seed) {
|
|
if (dt < 0 || dt > 0.45) return [0, 0];
|
|
const a = W * 0.011 * Math.exp(-dt * 9);
|
|
return [a * Math.sin(dt * 71 + seed * 6), a * Math.cos(dt * 53 + seed * 9)];
|
|
}
|
|
|
|
function beatsIn(D) { return D >= 6 ? 3 : D >= 3.5 ? 2 : 1; }
|
|
|
|
// Where the camera sits for shot j of picture slot k
|
|
function framing(k, j, visit) {
|
|
if (j === 0 && visit === 0) {
|
|
const px = 0.5 + (rnd(k, 1) - 0.5) * 0.3, py = 0.5 + (rnd(k, 2) - 0.5) * 0.2;
|
|
const zin = rnd(k, 3) < 0.5;
|
|
return { z0: zin ? 1.0 : 1.1, z1: zin ? 1.1 : 1.0, px0: px, py0: py, px1: 1 - px, py1: py, fit: true };
|
|
}
|
|
const z = 1.45 + rnd(k, 10 + j) * 0.4;
|
|
const px = 0.2 + rnd(k, 20 + j) * 0.6, py = 0.2 + rnd(k, 30 + j) * 0.6;
|
|
const dx = (rnd(k, 40 + j) - 0.5) * 0.14, dy = (rnd(k, 50 + j) - 0.5) * 0.14;
|
|
return { z0: z, z1: z * 1.06, px0: px, py0: py, px1: clamp(px + dx, 0, 1), py1: clamp(py + dy, 0, 1), fit: false };
|
|
}
|
|
|
|
function drawCam(ctx, im, W, H, z, px, py, fit) {
|
|
const ar = im.w / im.h, fr = W / H;
|
|
const badFit = Math.max(ar / fr, fr / ar) > 1.45;
|
|
if (fit && badFit) {
|
|
ctx.drawImage(blurredBg(im, W, H), -W * 0.03, -H * 0.03, W * 1.06, H * 1.06);
|
|
const s = Math.min(W * 0.94 / im.w, H * 0.94 / im.h) * (1 + (z - 1) * 0.5);
|
|
const dw = im.w * s, dh = im.h * s;
|
|
ctx.drawImage(im.bmp, (W - dw) / 2, (H - dh) / 2, dw, dh);
|
|
return;
|
|
}
|
|
if (badFit) z = 1 + (z - 1) * 0.35;
|
|
const s = Math.max(W / im.w, H / im.h) * z;
|
|
const dw = im.w * s, dh = im.h * s;
|
|
ctx.drawImage(im.bmp, -(dw - W) * px, -(dh - H) * py, dw, dh);
|
|
}
|
|
|
|
function renderShots(ctx, k, ti, D, W, H) {
|
|
const n = images.length, idx = k % n, visit = Math.floor(k / n);
|
|
const B = beatsIn(D), bl = D / B;
|
|
const j = Math.min(B - 1, Math.floor(ti / bl));
|
|
const tb = ti - j * bl;
|
|
const f = framing(k, j, visit);
|
|
const d = clamp(tb / bl, 0, 1.3);
|
|
let z = lerp(f.z0, f.z1, d), px = lerp(f.px0, f.px1, d), py = lerp(f.py0, f.py1, d);
|
|
const SNAP = Math.min(0.3, bl * 0.25);
|
|
if (j > 0 && tb < SNAP) {
|
|
const p = framing(k, j - 1, visit);
|
|
if (!p.fit) {
|
|
const e = easeBack(tb / SNAP);
|
|
z = lerp(p.z1, z, e); px = lerp(p.px1, px, e); py = lerp(p.py1, py, e);
|
|
}
|
|
}
|
|
drawCam(ctx, images[idx], W, H, z, px, py, f.fit);
|
|
}
|
|
|
|
function comicPanelsFor(k) {
|
|
if (!$("panels").checked) return 0;
|
|
const n = images.length;
|
|
if (k % 5 !== 4 || n < 2) return 0;
|
|
return n >= 3 && rnd(k, 99) < 0.6 ? 3 : 2;
|
|
}
|
|
function panelAt(i, D) { return i * Math.min(D * 0.3, 1.6); }
|
|
function panelImpact(ti, D, np) {
|
|
for (let i = np - 1; i >= 1; i--) { const at = panelAt(i, D); if (ti >= at) return ti - at - 0.2; }
|
|
return -1;
|
|
}
|
|
function panelRects(np, W, H) {
|
|
const G = Math.round(Math.min(W, H) * 0.025);
|
|
if (W > H) {
|
|
if (np === 2) { const w = (W - 3 * G) / 2; return [[G, G, w, H - 2 * G], [2 * G + w, G, w, H - 2 * G]]; }
|
|
const w1 = (W - 3 * G) * 0.55, w2 = W - 3 * G - w1, h2 = (H - 3 * G) / 2;
|
|
return [[G, G, w1, H - 2 * G], [2 * G + w1, G, w2, h2], [2 * G + w1, 2 * G + h2, w2, h2]];
|
|
}
|
|
const h = (H - (np + 1) * G) / np;
|
|
return Array.from({ length: np }, (_, i) => [G, G + i * (h + G), W - 2 * G, h]);
|
|
}
|
|
function renderPanels(ctx, k, ti, D, W, H, np) {
|
|
const n = images.length;
|
|
const base = ctx.globalAlpha;
|
|
ctx.fillStyle = "#fff";
|
|
ctx.fillRect(0, 0, W, H);
|
|
panelRects(np, W, H).forEach(([x, y, w, h], i) => {
|
|
const at = panelAt(i, D);
|
|
if (ti < at) return;
|
|
const im = images[((k - (np - 1 - i)) % n + n) % n];
|
|
const e = easeOut((ti - at) / 0.35);
|
|
const dir = rnd(k, 200 + i) < 0.5 ? -1 : 1;
|
|
const ox = W > H ? 0 : (1 - e) * dir * W * 0.3;
|
|
const oy = W > H ? (1 - e) * dir * H * 0.3 : 0;
|
|
const pt = clamp((ti - at) / Math.max(1, D - at), 0, 1.3);
|
|
ctx.save();
|
|
ctx.globalAlpha = base * e;
|
|
ctx.translate(x + ox, y + oy);
|
|
ctx.beginPath();
|
|
ctx.rect(0, 0, w, h);
|
|
ctx.clip();
|
|
drawCam(ctx, im, w, h, lerp(1.04, 1.16, pt), lerp(0.42, 0.58, pt), 0.5, false);
|
|
ctx.restore();
|
|
ctx.save();
|
|
ctx.globalAlpha = base * e;
|
|
ctx.lineWidth = Math.min(W, H) * 0.008;
|
|
ctx.strokeStyle = "#000";
|
|
ctx.strokeRect(x + ox, y + oy, w, h);
|
|
ctx.restore();
|
|
});
|
|
}
|
|
|
|
const TRANS = ["cut", "slide", "flash", "slide", "zoom", "cut"];
|
|
function transType(k) { return k === 0 ? "none" : TRANS[Math.floor(rnd(k, 300) * TRANS.length)]; }
|
|
|
|
function renderSlot(ctx, k, ti, D, W, H) {
|
|
const np = comicPanelsFor(k);
|
|
let dt = -1;
|
|
if (np) dt = panelImpact(ti, D, np);
|
|
else {
|
|
const B = beatsIn(D), bl = D / B, j = Math.min(B - 1, Math.floor(ti / bl));
|
|
if (j > 0) dt = ti - j * bl - Math.min(0.3, bl * 0.25) * 0.6;
|
|
else if (transType(k) === "cut") dt = ti;
|
|
}
|
|
const [sx, sy] = shakeAt(dt, W, k);
|
|
const amp = Math.hypot(sx, sy);
|
|
ctx.save();
|
|
if (amp > 0.3) {
|
|
const s = 1 + 2.4 * amp / Math.min(W, H);
|
|
ctx.translate(W / 2 + sx, H / 2 + sy);
|
|
ctx.scale(s, s);
|
|
ctx.translate(-W / 2, -H / 2);
|
|
}
|
|
if (np) renderPanels(ctx, k, ti, D, W, H, np);
|
|
else renderShots(ctx, k, ti, D, W, H);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawFrameComic(ctx, t, W, H) {
|
|
const D = slotLen();
|
|
const last = Math.max(0, Math.ceil(duration / D - 0.001) - 1);
|
|
const k = Math.min(last, Math.floor(t / D));
|
|
const ti = t - k * D;
|
|
const tr = transType(k);
|
|
const TR = Math.min(0.4, D * 0.2);
|
|
if (k === 0 || tr === "cut" || ti >= TR) { renderSlot(ctx, k, ti, D, W, H); return; }
|
|
const e = easeOut(ti / TR);
|
|
const pt = ti + D;
|
|
if (tr === "slide") {
|
|
const horiz = W > H ? rnd(k, 301) < 0.75 : rnd(k, 301) < 0.4;
|
|
const dir = rnd(k, 302) < 0.5 ? -1 : 1;
|
|
const gut = Math.min(W, H) * 0.03;
|
|
const Wx = horiz ? W + gut : 0, Hy = horiz ? 0 : H + gut;
|
|
ctx.save();
|
|
ctx.translate(-dir * e * Wx, -dir * e * Hy);
|
|
renderSlot(ctx, k - 1, pt, D, W, H);
|
|
ctx.restore();
|
|
ctx.save();
|
|
ctx.translate(dir * (1 - e) * Wx, dir * (1 - e) * Hy);
|
|
renderSlot(ctx, k, ti, D, W, H);
|
|
ctx.restore();
|
|
} else if (tr === "flash") {
|
|
const half = TR / 2;
|
|
if (ti < half) renderSlot(ctx, k - 1, pt, D, W, H);
|
|
else renderSlot(ctx, k, ti, D, W, H);
|
|
const a = ti < half ? ti / half : 1 - (ti - half) / half;
|
|
ctx.fillStyle = `rgba(255,255,255,${(0.9 * clamp(a, 0, 1)).toFixed(3)})`;
|
|
ctx.fillRect(0, 0, W, H);
|
|
} else {
|
|
renderSlot(ctx, k, ti, D, W, H);
|
|
ctx.save();
|
|
ctx.globalAlpha = 1 - e;
|
|
const s = 1 + e * 0.6;
|
|
ctx.translate(W / 2, H / 2);
|
|
ctx.scale(s, s);
|
|
ctx.translate(-W / 2, -H / 2);
|
|
renderSlot(ctx, k - 1, pt, D, W, H);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
let dotTile = null;
|
|
function drawTexture(ctx, W, H) {
|
|
if (!$("texture").checked) return;
|
|
if (!dotTile) {
|
|
dotTile = document.createElement("canvas");
|
|
dotTile.width = dotTile.height = 6;
|
|
const x = dotTile.getContext("2d");
|
|
x.fillStyle = "#000";
|
|
x.beginPath();
|
|
x.arc(3, 3, 1.3, 0, Math.PI * 2);
|
|
x.fill();
|
|
}
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.16;
|
|
ctx.fillStyle = ctx.createPattern(dotTile, "repeat");
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.globalAlpha = 1;
|
|
const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.75);
|
|
g.addColorStop(0, "rgba(0,0,0,0)");
|
|
g.addColorStop(1, "rgba(0,0,0,0.38)");
|
|
ctx.fillStyle = g;
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.restore();
|
|
}
|
|
|
|
// Small preview on the page
|
|
let work = null;
|
|
function workCanvas(W, H) {
|
|
if (!work || work.width !== W || work.height !== H) {
|
|
work = document.createElement("canvas");
|
|
work.width = W; work.height = H;
|
|
}
|
|
return work;
|
|
}
|
|
function showOnScreen(src) {
|
|
const v = $("view");
|
|
const [W, H] = [src.width, src.height];
|
|
const vw = Math.round(W / 3), vh = Math.round(H / 3);
|
|
if (v.width !== vw || v.height !== vh) { v.width = vw; v.height = vh; }
|
|
const x = v.getContext("2d");
|
|
x.imageSmoothingQuality = "high";
|
|
x.drawImage(src, 0, 0, vw, vh);
|
|
}
|
|
function drawStill() {
|
|
scheduleSave();
|
|
if (busy) return;
|
|
const [W, H] = size();
|
|
const c = workCanvas(W, H);
|
|
const ctx = c.getContext("2d");
|
|
if (!images.length || !duration) {
|
|
ctx.fillStyle = "#111";
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.fillStyle = "#888";
|
|
ctx.font = `700 ${W > H ? 54 : 50}px ${FONT}`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(!duration ? "Pick an mp3 to see a preview" : "Add pictures to see a preview", W / 2, H / 2);
|
|
showOnScreen(c);
|
|
return;
|
|
}
|
|
// a moment just after the title so subtitles show too
|
|
const caps = buildCaptions(ctx, W, H);
|
|
let t = Math.min(duration * 0.5, titleTime() + 2);
|
|
if (caps.length) {
|
|
const c1 = caps.find(q => q.a >= titleTime());
|
|
if (c1) t = Math.min(duration - 0.01, (c1.a + c1.b) / 2);
|
|
}
|
|
drawFrame(ctx, t, W, H, caps);
|
|
showOnScreen(c);
|
|
}
|
|
|
|
function refresh() {
|
|
drawThumbs();
|
|
shapeWarning();
|
|
drawStill();
|
|
}
|
|
|
|
// ---------- Live preview ----------
|
|
let pv = null;
|
|
function stopPreview() {
|
|
if (!pv) return;
|
|
cancelAnimationFrame(pv.raf);
|
|
pv.el.pause();
|
|
URL.revokeObjectURL(pv.url);
|
|
pv = null;
|
|
$("pvBtn").textContent = "Play 20 second preview";
|
|
drawStill();
|
|
}
|
|
$("pvBtn").addEventListener("click", () => {
|
|
if (pv) { stopPreview(); return; }
|
|
if (!audioFile || !images.length) { status("Pick the mp3 and add pictures first.", true); return; }
|
|
const [W, H] = size();
|
|
const c = workCanvas(W, H), ctx = c.getContext("2d");
|
|
const caps = buildCaptions(ctx, W, H);
|
|
const url = URL.createObjectURL(audioFile);
|
|
const el = new Audio(url);
|
|
pv = { el, url, raf: 0 };
|
|
$("pvBtn").textContent = "Stop preview";
|
|
el.play().catch(() => {});
|
|
const loop = () => {
|
|
if (!pv) return;
|
|
const t = el.currentTime;
|
|
if (el.ended || t >= Math.min(duration - 0.05, 20)) { stopPreview(); return; }
|
|
drawFrame(ctx, t, W, H, caps);
|
|
showOnScreen(c);
|
|
pv.raf = requestAnimationFrame(loop);
|
|
};
|
|
loop();
|
|
});
|
|
|
|
// ---------- Encoding ----------
|
|
async function pickVideoConfig(W, H) {
|
|
for (const codec of ["avc1.640028", "avc1.4d0028", "avc1.42e028", "avc1.64002a", "avc1.640033"]) {
|
|
const cfg = { codec, width: W, height: H, bitrate: 6_000_000, framerate: FPS, avc: { format: "avc" } };
|
|
try { if ((await VideoEncoder.isConfigSupported(cfg)).supported) return [cfg, "avc"]; } catch {}
|
|
}
|
|
// Backup plan if the computer can't do H.264: VP9 (YouTube takes it, TikTok may not)
|
|
for (const codec of ["vp09.00.40.08", "vp09.00.41.08", "vp09.00.50.08"]) {
|
|
const cfg = { codec, width: W, height: H, bitrate: 6_000_000, framerate: FPS };
|
|
try { if ((await VideoEncoder.isConfigSupported(cfg)).supported) return [cfg, "vp9"]; } catch {}
|
|
}
|
|
return [null, null];
|
|
}
|
|
async function pickAudioConfig() {
|
|
const aac = { codec: "mp4a.40.2", sampleRate: RATE, numberOfChannels: 1, bitrate: 128000 };
|
|
try { if ((await AudioEncoder.isConfigSupported(aac)).supported) return [aac, "aac"]; } catch {}
|
|
const opus = { codec: "opus", sampleRate: RATE, numberOfChannels: 1, bitrate: 128000 };
|
|
try { if ((await AudioEncoder.isConfigSupported(opus)).supported) return [opus, "opus"]; } catch {}
|
|
return [null, null];
|
|
}
|
|
|
|
function setBusy(b) {
|
|
busy = b;
|
|
$("makeBtn").disabled = b;
|
|
$("pvBtn").disabled = b;
|
|
$("audioBtn").disabled = b;
|
|
$("srtBtn").disabled = b;
|
|
$("saveProjBtn").disabled = b;
|
|
$("openProjBtn").disabled = b;
|
|
$("newProjBtn").disabled = b;
|
|
document.querySelectorAll("input[name=shape], #subsOn, #every, #style, #panels, #texture").forEach(x => x.disabled = b);
|
|
$("stopBtn").classList.toggle("hidden", !b);
|
|
drawThumbs();
|
|
}
|
|
|
|
$("stopBtn").addEventListener("click", () => { stopFlag = true; status("Stopping..."); });
|
|
|
|
$("makeBtn").addEventListener("click", async () => {
|
|
stopPreview();
|
|
if (!audio) { status("Pick the mp3 first, in step 1.", true); return; }
|
|
if (!images.length) { status("Add at least one picture in step 2.", true); return; }
|
|
if (!("VideoEncoder" in window) || !("AudioEncoder" in window)) {
|
|
status("This browser can't make videos. Open this page in Brave or Chrome.", true);
|
|
return;
|
|
}
|
|
if ($("subsOn").checked && !cues.length && !$("subText").value.trim()) {
|
|
status("Subtitles are on but there's no subtitle file or text. Add one in step 4, or untick Show subtitles.", true);
|
|
return;
|
|
}
|
|
autoName();
|
|
if (!$("fname").value.trim()) $("fname").value = "video";
|
|
|
|
const [W, H] = size();
|
|
const [vcfg, vcodec] = await pickVideoConfig(W, H);
|
|
const [acfg, acodec] = await pickAudioConfig();
|
|
if (!vcfg || !acfg) { status("This computer's browser can't encode the video format YouTube and TikTok need. Try Chrome.", true); return; }
|
|
|
|
setBusy(true);
|
|
stopFlag = false;
|
|
$("saveBtn").classList.add("hidden");
|
|
$("player").classList.add("hidden");
|
|
bar(0);
|
|
|
|
let encErr = null;
|
|
try {
|
|
const canvas = workCanvas(W, H);
|
|
const ctx = canvas.getContext("2d", { alpha: false });
|
|
const caps = buildCaptions(ctx, W, H);
|
|
|
|
const muxer = new Muxer({
|
|
target: new ArrayBufferTarget(),
|
|
video: { codec: vcodec, width: W, height: H, frameRate: FPS },
|
|
audio: { codec: acodec, numberOfChannels: 1, sampleRate: RATE },
|
|
fastStart: "in-memory",
|
|
firstTimestampBehavior: "offset",
|
|
});
|
|
const venc = new VideoEncoder({
|
|
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
|
|
error: (e) => { encErr = e; },
|
|
});
|
|
venc.configure(vcfg);
|
|
const aenc = new AudioEncoder({
|
|
output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
|
|
error: (e) => { encErr = e; },
|
|
});
|
|
aenc.configure(acfg);
|
|
|
|
let aPos = 0;
|
|
const feedAudio = (untilSec) => {
|
|
const until = Math.min(audio.length, Math.round(untilSec * RATE));
|
|
while (aPos < until) {
|
|
const n = Math.min(4800, until - aPos);
|
|
const ad = new AudioData({
|
|
format: "f32-planar", sampleRate: RATE, numberOfFrames: n, numberOfChannels: 1,
|
|
timestamp: Math.round(aPos * 1e6 / RATE), data: audio.slice(aPos, aPos + n),
|
|
});
|
|
aenc.encode(ad);
|
|
ad.close();
|
|
aPos += n;
|
|
}
|
|
};
|
|
|
|
const totalFrames = Math.ceil((duration + 0.3) * FPS);
|
|
const t0 = performance.now();
|
|
status("Making the video...");
|
|
for (let i = 0; i < totalFrames; i++) {
|
|
if (stopFlag || encErr) break;
|
|
const t = i / FPS;
|
|
feedAudio(t + 1);
|
|
drawFrame(ctx, Math.min(t, duration - 0.001), W, H, caps);
|
|
const vf = new VideoFrame(canvas, { timestamp: Math.round(i * 1e6 / FPS), duration: Math.round(1e6 / FPS) });
|
|
venc.encode(vf, { keyFrame: i % (FPS * 2) === 0 });
|
|
vf.close();
|
|
while (venc.encodeQueueSize > 8) {
|
|
await Promise.race([new Promise(r => venc.addEventListener("dequeue", r, { once: true })), sleep(20)]);
|
|
}
|
|
if (i % 10 === 0) {
|
|
const frac = i / totalFrames;
|
|
const elapsed = (performance.now() - t0) / 1000;
|
|
const left = frac > 0.02 ? elapsed / frac - elapsed : null;
|
|
bar(frac);
|
|
if (i % 30 === 0) showOnScreen(canvas);
|
|
status(`Making the video... ${Math.round(frac * 100)}% done` + (left !== null ? `, about ${fmtTime(left)} left` : ""));
|
|
await tick();
|
|
}
|
|
}
|
|
if (encErr) throw encErr;
|
|
if (stopFlag) {
|
|
try { venc.close(); aenc.close(); } catch {}
|
|
status("Stopped. Nothing was saved.");
|
|
bar(0);
|
|
return;
|
|
}
|
|
feedAudio(duration + 1);
|
|
status("Finishing up...");
|
|
await venc.flush();
|
|
await aenc.flush();
|
|
muxer.finalize();
|
|
venc.close(); aenc.close();
|
|
|
|
const blob = new Blob([muxer.target.buffer], { type: "video/mp4" });
|
|
if (outUrl) URL.revokeObjectURL(outUrl);
|
|
outUrl = URL.createObjectURL(blob);
|
|
outName = (safeName($("fname").value) || "video") + ".mp4";
|
|
$("player").src = outUrl;
|
|
$("player").classList.remove("hidden");
|
|
$("saveBtn").classList.remove("hidden");
|
|
bar(1);
|
|
const mb = (blob.size / 1048576).toFixed(0);
|
|
status(`Done! ${fmtTime(duration)} of video, ${mb} MB. Press play to check it, then click Save MP4.`
|
|
+ (vcodec === "vp9" ? " Note: this computer couldn't make the usual H.264 video, so it used VP9. YouTube takes it, but TikTok might refuse it." : "")
|
|
+ (vcodec !== "vp9" && acodec === "opus" ? " Note: this browser couldn't make AAC sound, so it used Opus. YouTube takes it fine." : ""));
|
|
} catch (e) {
|
|
console.error(e);
|
|
status("Something went wrong: " + (e.message || e), true);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
});
|
|
|
|
$("saveBtn").addEventListener("click", () => {
|
|
if (!outUrl) return;
|
|
const a = document.createElement("a");
|
|
a.href = outUrl;
|
|
a.download = outName;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
status(`Saved ${outName} to your Downloads folder.`);
|
|
});
|
|
|
|
// ---------- Projects (save, open, auto-save) ----------
|
|
function idb() {
|
|
return new Promise((res, rej) => {
|
|
const r = indexedDB.open("reel", 1);
|
|
r.onupgradeneeded = () => r.result.createObjectStore("projects");
|
|
r.onsuccess = () => res(r.result);
|
|
r.onerror = () => rej(r.error);
|
|
});
|
|
}
|
|
async function idbGet(key) {
|
|
const db = await idb();
|
|
return new Promise((res, rej) => {
|
|
const q = db.transaction("projects").objectStore("projects").get(key);
|
|
q.onsuccess = () => res(q.result);
|
|
q.onerror = () => rej(q.error);
|
|
});
|
|
}
|
|
async function idbPut(key, val) {
|
|
const db = await idb();
|
|
return new Promise((res, rej) => {
|
|
const t = db.transaction("projects", "readwrite");
|
|
if (val === undefined) t.objectStore("projects").delete(key);
|
|
else t.objectStore("projects").put(val, key);
|
|
t.oncomplete = () => res();
|
|
t.onerror = () => rej(t.error);
|
|
});
|
|
}
|
|
|
|
function snapshot() {
|
|
return {
|
|
v: 1,
|
|
savedAt: Date.now(),
|
|
title: $("title").value,
|
|
fname: $("fname").value,
|
|
shape: shape(),
|
|
every: $("every").value,
|
|
style: $("style").value,
|
|
panels: $("panels").checked,
|
|
texture: $("texture").checked,
|
|
subsOn: $("subsOn").checked,
|
|
subText: $("subText").value,
|
|
srtName, srtText,
|
|
audio: audioFile ? { name: audioFile.name, type: audioFile.type || "audio/mpeg", blob: audioFile } : null,
|
|
images: images.map(im => ({ name: im.name, type: im.file.type || "image/png", blob: im.file })),
|
|
};
|
|
}
|
|
function isEmpty(p) {
|
|
return !p || (!p.audio && !(p.images || []).length && !(p.title || "").trim() && !(p.subText || "").trim() && !p.srtText);
|
|
}
|
|
|
|
function scheduleSave() {
|
|
if (restoring) return;
|
|
clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(async () => {
|
|
try {
|
|
const p = snapshot();
|
|
await idbPut("last", isEmpty(p) ? undefined : p);
|
|
} catch (e) { console.warn("Auto-save failed", e); }
|
|
}, 1200);
|
|
}
|
|
|
|
function clearProject() {
|
|
images.forEach(im => { URL.revokeObjectURL(im.thumb); im.bmp.close(); });
|
|
images = [];
|
|
audio = null; duration = 0; audioFile = null;
|
|
$("audioInfo").textContent = "";
|
|
$("title").value = "";
|
|
$("fname").value = "";
|
|
$("subText").value = "";
|
|
setSrt("", "");
|
|
if (outUrl) { URL.revokeObjectURL(outUrl); outUrl = null; }
|
|
$("player").classList.add("hidden");
|
|
$("saveBtn").classList.add("hidden");
|
|
bar(0);
|
|
}
|
|
|
|
async function restore(p) {
|
|
restoring = true;
|
|
try {
|
|
clearProject();
|
|
status("Opening the project...");
|
|
$("title").value = p.title || "";
|
|
const r = document.querySelector(`input[name=shape][value="${p.shape === "v" ? "v" : "h"}"]`);
|
|
if (r) r.checked = true;
|
|
if (p.every != null) $("every").value = String(p.every);
|
|
$("style").value = p.style === "calm" ? "calm" : "comic";
|
|
$("panels").checked = p.panels !== false;
|
|
$("texture").checked = !!p.texture;
|
|
$("subsOn").checked = p.subsOn !== false;
|
|
$("subsBox").classList.toggle("hidden", !$("subsOn").checked);
|
|
$("subText").value = p.subText || "";
|
|
setSrt(p.srtName || "", p.srtText || "");
|
|
if (p.audio) await loadAudio(new File([p.audio.blob], p.audio.name, { type: p.audio.type }), true);
|
|
if ((p.images || []).length) await addImages(p.images.map(im => new File([im.blob], im.name, { type: im.type })));
|
|
$("fname").value = p.fname || "";
|
|
if (!$("fname").value) autoName(true);
|
|
} finally {
|
|
restoring = false;
|
|
}
|
|
refresh();
|
|
status(`Opened "${p.title || "untitled project"}". Pick up where you left off.`);
|
|
}
|
|
|
|
// Save to a file: blobs become base64 so it is one plain file
|
|
function blobToB64(b) {
|
|
return new Promise((res, rej) => {
|
|
const r = new FileReader();
|
|
r.onload = () => res(String(r.result).split(",")[1] || "");
|
|
r.onerror = () => rej(r.error);
|
|
r.readAsDataURL(b);
|
|
});
|
|
}
|
|
async function b64ToBlob(b64, type) {
|
|
return (await fetch(`data:${type};base64,${b64}`)).blob();
|
|
}
|
|
|
|
$("saveProjBtn").addEventListener("click", async () => {
|
|
const p = snapshot();
|
|
if (isEmpty(p)) { status("There's nothing to save yet.", true); return; }
|
|
status("Packing up the project...");
|
|
try {
|
|
const out = { ...p, audio: null, images: [] };
|
|
if (p.audio) out.audio = { name: p.audio.name, type: p.audio.type, data: await blobToB64(p.audio.blob) };
|
|
for (const im of p.images) out.images.push({ name: im.name, type: im.type, data: await blobToB64(im.blob) });
|
|
const url = URL.createObjectURL(new Blob([JSON.stringify(out)], { type: "application/json" }));
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = (safeName($("title").value) || "project") + ".reel";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
status(`Saved ${a.download} to your Downloads folder. Use Open project to load it again.`);
|
|
} catch (e) {
|
|
console.error(e);
|
|
status("Couldn't save the project: " + (e.message || e), true);
|
|
}
|
|
});
|
|
|
|
$("openProjBtn").addEventListener("click", () => $("projFile").click());
|
|
$("projFile").addEventListener("change", async () => {
|
|
const f = $("projFile").files[0];
|
|
$("projFile").value = "";
|
|
if (!f) return;
|
|
if (busy) { status("Wait for the video to finish first.", true); return; }
|
|
try {
|
|
status("Reading the project file...");
|
|
const raw = JSON.parse(await f.text());
|
|
const p = { ...raw, audio: null, images: [] };
|
|
if (raw.audio) p.audio = { name: raw.audio.name, type: raw.audio.type, blob: await b64ToBlob(raw.audio.data, raw.audio.type) };
|
|
for (const im of raw.images || []) p.images.push({ name: im.name, type: im.type, blob: await b64ToBlob(im.data, im.type) });
|
|
$("resumeBox").classList.add("hidden");
|
|
await restore(p);
|
|
} catch (e) {
|
|
console.error(e);
|
|
status("That doesn't look like a Reel project file.", true);
|
|
}
|
|
});
|
|
|
|
$("newProjBtn").addEventListener("click", async () => {
|
|
if (busy) return;
|
|
if (!isEmpty(snapshot()) && !confirm("Start a new project? Anything you haven't saved as a project file will be cleared.")) return;
|
|
clearProject();
|
|
$("resumeBox").classList.add("hidden");
|
|
try { await idbPut("last", undefined); } catch {}
|
|
refresh();
|
|
status("Ready.");
|
|
});
|
|
|
|
// On load, offer to resume the last project
|
|
(async () => {
|
|
try {
|
|
const p = await idbGet("last");
|
|
if (isEmpty(p)) return;
|
|
const n = (p.images || []).length;
|
|
const when = new Date(p.savedAt || Date.now()).toLocaleString();
|
|
$("resumeText").textContent = `You have an unfinished project: "${p.title || "untitled"}", ${n} picture${n === 1 ? "" : "s"}${p.audio ? " and audio" : ""}. Last changed ${when}.`;
|
|
$("resumeBox").classList.remove("hidden");
|
|
$("resumeBtn").onclick = async () => { $("resumeBox").classList.add("hidden"); await restore(p); };
|
|
$("freshBtn").onclick = async () => { $("resumeBox").classList.add("hidden"); try { await idbPut("last", undefined); } catch {} };
|
|
} catch (e) { console.warn("Couldn't check for a saved project", e); }
|
|
})();
|
|
|
|
refresh();
|
|
</script>
|
|
</body>
|
|
</html>
|