Files
Reel/Reel.html
T
2026-10-18 21:08:26 +00:00

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>