Files
VideoAnnotationTool/videoaudioannotate.html
2026-05-10 12:34:40 +02:00

706 lines
32 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Timeline Studio | Perfect FFmpeg + Clean Play + CD Prefix</title>
<style>
* { box-sizing: border-box; }
body {
background: #0a0a0f;
font-family: 'Segoe UI', Roboto, system-ui, sans-serif;
display: flex;
justify-content: center;
min-height: 100vh;
margin: 0;
padding: 20px;
}
.app-container {
max-width: 1500px;
width: 100%;
background: #14141f;
border-radius: 32px;
padding: 24px;
box-shadow: 0 20px 35px -12px rgba(0,0,0,0.6);
}
h1 { font-size: 1.6rem; margin: 0 0 6px; color: #f0f0f0; }
.sub { color: #aaa; margin-bottom: 24px; border-left: 3px solid #f59e0b; padding-left: 12px; font-size: 0.85rem; }
.video-section, .timeline-panel, .ffmpeg-panel, .export-panel {
background: #0b0b12;
border-radius: 24px;
padding: 16px;
margin-bottom: 20px;
}
.upload-area { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; margin-bottom: 18px; }
.file-label { background: #3b82f6; padding: 8px 18px; border-radius: 60px; color: white; cursor: pointer; font-size: 0.85rem; }
video { width: 100%; border-radius: 20px; max-height: 380px; background: #000; }
.video-controls { display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap; align-items: center; }
button { background: #2c2c3a; border: none; color: white; padding: 8px 18px; border-radius: 40px; cursor: pointer; font-size: 0.8rem; transition: 0.2s; }
button.primary { background: #3b82f6; }
button.primary:hover { background: #2563eb; }
button.success { background: #10b981; }
button.warning { background: #f59e0b; color: #14141f; }
button.ffmpeg-btn { background: #8b5cf6; }
.scrub-time { background: #0f0f17; padding: 5px 12px; border-radius: 40px; font-family: monospace; color: #ddd; }
.timeline-header { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; margin-bottom: 16px; }
.add-marker-group { display: flex; gap: 10px; flex-wrap: wrap; }
.timestamp-input-group { display: flex; gap: 8px; background: #0b0b12; padding: 4px 12px; border-radius: 40px; align-items: center; }
.timestamp-input-group input { background: #1a1a24; border: 1px solid #3b3b4a; color: white; padding: 6px 10px; border-radius: 30px; width: 130px; font-family: monospace; }
.timeline-canvas-wrapper { position: relative; background: #08080e; border-radius: 16px; padding: 8px 0; margin: 10px 0; }
canvas { width: 100%; height: 70px; background: #0f0f17; border-radius: 12px; cursor: pointer; }
.timeline-ruler { display: flex; justify-content: space-between; padding: 4px 8px; font-family: monospace; font-size: 10px; color: #6a6a7a; }
.playhead { position: absolute; top: 0; width: 3px; height: 100%; background: #facc15; pointer-events: none; border-radius: 2px; transition: left 0.05s linear; }
.markers-list { max-height: 240px; overflow-y: auto; margin-top: 16px; display: flex; flex-direction: column; gap: 8px; }
.marker-item { background: #1a1a24; border-radius: 16px; padding: 10px 14px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; border-left: 3px solid #f59e0b; }
.marker-time input { width: 85px; background: #0b0b12; border: 1px solid #3b3b4a; color: white; border-radius: 20px; padding: 4px 8px; font-family: monospace; }
.audio-file-label { background: #2c2c3a; padding: 4px 10px; border-radius: 30px; font-size: 0.7rem; cursor: pointer; }
.audio-name { flex: 1; font-size: 0.75rem; color: #ccc; font-family: monospace; }
button.small { padding: 4px 10px; font-size: 0.7rem; }
.ffmpeg-command { background: #0a0a0f; padding: 12px; border-radius: 16px; font-family: monospace; font-size: 0.7rem; word-break: break-all; color: #a5f7a5; border: 1px solid #2a2a36; margin-top: 8px; max-height: 200px; overflow-y: auto; white-space: pre-wrap; }
.export-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; }
.empty-message { color: #6b6b7a; text-align: center; padding: 20px; font-style: italic; }
.share-url { background: #0b0b12; padding: 8px 12px; border-radius: 12px; font-size: 0.7rem; font-family: monospace; color: #3b82f6; overflow-x: auto; white-space: nowrap; margin-top: 8px; }
.checkbox-row { display: flex; align-items: center; gap: 12px; background: #1a1a24; padding: 8px 16px; border-radius: 40px; }
.checkbox-row label { font-size: 0.8rem; color: #ddd; cursor: pointer; }
.cd-input-row { display: flex; align-items: center; gap: 12px; background: #1a1a24; padding: 8px 16px; border-radius: 40px; flex-wrap: wrap; }
.cd-input-row input[type="text"] { background: #0b0b12; border: 1px solid #3b3b4a; color: white; padding: 6px 12px; border-radius: 30px; font-family: monospace; font-size: 0.75rem; width: 240px; }
.note-box { background: #1a333a; padding: 8px 12px; border-radius: 12px; margin-top: 10px; font-size: 0.7rem; color: #7dd3fc; }
</style>
</head>
<body>
<div class="app-container">
<h1>Timeline Annotation Studio</h1>
<div class="sub">Optional cd command</div>
<div class="video-section">
<div class="upload-area">
<label class="file-label">📁 Upload Video<input type="file" id="videoUpload" accept="video/mp4,video/webm, video/ogg" style="display:none"></label>
<div id="videoStatus" style="color:#aaa;">No video loaded</div>
</div>
<video id="videoPlayer" controls></video>
<div class="video-controls">
<span class="scrub-time" id="currentTimeDisplay">0:00.00</span>
<button id="specialPlayBtn" class="primary">▶ Special Play (sync audio)</button>
<button id="exportAudioBtn" class="success">🎵 Export Full Mix WAV</button>
<button id="batchAutoBtn" class="warning">Batch Audio (auto timestamp)</button>
</div>
</div>
<div class="timeline-panel">
<div class="timeline-header">
<span class="timeline-title">🎚️ Audio Markers (click canvas to seek)</span>
<div class="add-marker-group">
<div class="timestamp-input-group">
<span>⏱️</span>
<input type="text" id="manualTimestamp" placeholder="12.5 or 1:23">
<button id="addManualBtn" class="small">+ Add</button>
</div>
<button id="addCurrentBtn" class="small">📍 At current time</button>
</div>
</div>
<div class="timeline-canvas-wrapper">
<canvas id="timelineCanvas" width="1000" height="70"></canvas>
<div class="timeline-ruler" id="timelineRuler"></div>
<div id="playhead" class="playhead"></div>
</div>
<div id="markersList" class="markers-list"></div>
</div>
<!-- QUERYSTRING EXPORT PANEL -->
<div class="export-panel">
<div class="export-header">
<strong style="color:#f0f0f0;">🔗 QueryString Export / Import (Timestamps only)</strong>
<div style="display: flex; gap: 8px;">
<button id="exportQueryBtn" class="ffmpeg-btn small">📤 Export to URL</button>
<button id="copyUrlBtn" class="small">📋 Copy URL</button>
<button id="clearMarkersBtn" class="small" style="background:#b91c1c;">🗑️ Clear All</button>
</div>
</div>
<div class="share-url" id="shareUrlDisplay">No markers yet. Add markers and click "Export to URL"</div>
</div>
<!-- FFMPEG PANEL -->
<div class="ffmpeg-panel">
<div class="export-header">
<strong style="color:#f0f0f0;">🎞️ FFmpeg Overlay Command (YOUR WORKING FORMAT)</strong>
<div class="checkbox-row">
<input type="checkbox" id="retainVideoAudioCheckbox" checked>
<label for="retainVideoAudioCheckbox">🎧 Include original video audio (volume=0.7)</label>
</div>
</div>
<div class="cd-input-row">
<input type="checkbox" id="addCdPrefixCheckbox" checked>
<label for="addCdPrefixCheckbox">📂 Add cd command prefix</label>
<input type="text" id="cdPathInput" value="/storage/emulated/0/Movies" placeholder="cd /path/to/dir">
<span style="font-size:0.7rem; color:#6a6a7a;">→ adds "cd /path &amp;&amp; ffmpeg ..."</span>
</div>
<div class="ffmpeg-command" id="ffmpegCommandDisplay"># Add markers to generate command...</div>
<div style="display: flex; gap: 10px; margin-top: 12px; justify-content: flex-end;">
<button id="refreshFfmpegCmd" class="ffmpeg-btn small">🔄 Refresh</button>
<button id="copyFfmpegBtn" class="copy-btn small">📋 Copy Command</button>
</div>
<div class="note-box">
💡 Uses YOUR exact format: [0:a]aresample=44100,volume=0.7[a0]; [N:a]aresample=44100,adelay=XXX|XXX,volume=2[aN]; then amix.
</div>
</div>
</div>
<script>
// DOM elements
const videoUpload = document.getElementById('videoUpload');
const videoPlayer = document.getElementById('videoPlayer');
const videoStatus = document.getElementById('videoStatus');
const currentTimeDisplay = document.getElementById('currentTimeDisplay');
const specialPlayBtn = document.getElementById('specialPlayBtn');
const exportAudioBtn = document.getElementById('exportAudioBtn');
const addCurrentBtn = document.getElementById('addCurrentBtn');
const addManualBtn = document.getElementById('addManualBtn');
const manualTimestampInput = document.getElementById('manualTimestamp');
const batchAutoBtn = document.getElementById('batchAutoBtn');
const refreshFfmpegCmd = document.getElementById('refreshFfmpegCmd');
const copyFfmpegBtn = document.getElementById('copyFfmpegBtn');
const ffmpegCommandDisplay = document.getElementById('ffmpegCommandDisplay');
const exportQueryBtn = document.getElementById('exportQueryBtn');
const copyUrlBtn = document.getElementById('copyUrlBtn');
const clearMarkersBtn = document.getElementById('clearMarkersBtn');
const shareUrlDisplay = document.getElementById('shareUrlDisplay');
const retainVideoAudioCheckbox = document.getElementById('retainVideoAudioCheckbox');
const addCdPrefixCheckbox = document.getElementById('addCdPrefixCheckbox');
const cdPathInput = document.getElementById('cdPathInput');
const markersContainer = document.getElementById('markersList');
const timelineCanvas = document.getElementById('timelineCanvas');
const playheadDiv = document.getElementById('playhead');
// Data
let markers = [];
let nextId = 1;
let syncActive = false;
let activeInterval = null;
let videoDuration = 0;
let currentVideoFile = null;
function formatTime(sec) {
if (isNaN(sec)) return "0:00.00";
const mins = Math.floor(sec / 60);
const secs = (sec % 60).toFixed(2);
return `${mins}:${secs.padStart(5, '0')}`;
}
function parseTimestamp(input) {
let str = String(input).trim();
if (str.includes(':')) {
const parts = str.split(':').map(Number);
if (parts.length === 2) return parts[0]*60 + parts[1];
if (parts.length === 3) return parts[0]*3600 + parts[1]*60 + parts[2];
return NaN;
}
return parseFloat(str);
}
function extractTimestampFromFilename(filename) {
const regex = /^\((\d{1,2})_(\d{1,2})(?:_(\d{1,2}))?\)/;
const match = filename.match(regex);
if (!match) return null;
if (match[3] !== undefined) return parseInt(match[1])*3600 + parseInt(match[2])*60 + parseInt(match[3]);
else return parseInt(match[1])*60 + parseInt(match[2]);
}
// QueryString functions
function exportMarkersToQueryString() {
const markerData = markers.map(m => ({ t: m.timestamp }));
const encoded = encodeURIComponent(JSON.stringify(markerData));
const url = new URL(window.location.href);
url.searchParams.set('timeline', encoded);
return url.toString();
}
function updateShareUrlDisplay() {
if (markers.length === 0) {
shareUrlDisplay.innerText = "No markers. Add some then click 'Export to URL'";
return;
}
shareUrlDisplay.innerText = exportMarkersToQueryString();
}
function copyShareUrl() {
const text = shareUrlDisplay.innerText;
if (text && text.startsWith('http')) {
navigator.clipboard.writeText(text);
alert("URL copied!");
}
}
function importMarkersFromUrl() {
const params = new URLSearchParams(window.location.search);
const timelineData = params.get('timeline');
if (!timelineData) return false;
try {
const decoded = JSON.parse(decodeURIComponent(timelineData));
if (Array.isArray(decoded)) {
markers = [];
nextId = 1;
for (const item of decoded) {
if (typeof item.t === 'number') {
markers.push({
id: nextId++,
timestamp: item.t,
audioBlob: null,
audioName: null,
autoDetected: false
});
}
}
renderMarkersList();
drawTimeline();
updateFfmpegCommand();
updateShareUrlDisplay();
return true;
}
} catch(e) { console.warn(e); }
return false;
}
function clearAllMarkers() {
if (confirm("Clear all markers?")) {
markers = [];
nextId = 1;
renderMarkersList();
drawTimeline();
updateFfmpegCommand();
updateShareUrlDisplay();
const url = new URL(window.location.href);
url.searchParams.delete('timeline');
window.history.pushState({}, '', url);
}
}
async function addMarker(timestampSec, audioBlob = null, audioName = null, auto = false) {
if (!videoDuration || videoDuration <= 0) { alert("Load video first"); return false; }
if (timestampSec < 0) timestampSec = 0;
if (timestampSec > videoDuration) timestampSec = videoDuration;
markers.push({ id: nextId++, timestamp: timestampSec, audioBlob, audioName, autoDetected: auto });
renderMarkersList();
drawTimeline();
updateFfmpegCommand();
updateShareUrlDisplay();
return true;
}
async function replaceMarkerAudio(markerId, file) {
const marker = markers.find(m => m.id === markerId);
if (!marker) return;
const arrayBuf = await file.arrayBuffer();
marker.audioBlob = new Blob([arrayBuf], { type: file.type });
marker.audioName = file.name;
renderMarkersList();
updateFfmpegCommand();
}
function deleteMarker(markerId) {
markers = markers.filter(m => m.id !== markerId);
renderMarkersList();
drawTimeline();
updateFfmpegCommand();
updateShareUrlDisplay();
}
function renderMarkersList() {
if (markers.length === 0) {
markersContainer.innerHTML = `<div class="empty-message">🎙️ No markers. Add manually or import from URL.</div>`;
return;
}
markersContainer.innerHTML = '';
markers.sort((a,b) => a.timestamp - b.timestamp).forEach(m => {
const div = document.createElement('div');
div.className = 'marker-item';
const timeInput = document.createElement('input');
timeInput.type = 'number';
timeInput.step = '0.01';
timeInput.value = m.timestamp.toFixed(2);
timeInput.style.width = '85px';
timeInput.addEventListener('change', (e) => {
let newVal = parseFloat(e.target.value);
if (isNaN(newVal)) newVal = 0;
if (videoDuration && newVal > videoDuration) newVal = videoDuration;
m.timestamp = newVal;
renderMarkersList();
drawTimeline();
updateFfmpegCommand();
updateShareUrlDisplay();
});
const replaceLabel = document.createElement('label');
replaceLabel.className = 'audio-file-label';
replaceLabel.innerHTML = '🎵 replace';
const replaceInput = document.createElement('input');
replaceInput.type = 'file';
replaceInput.accept = 'audio/*';
replaceInput.style.display = 'none';
replaceInput.onchange = async (e) => { if (e.target.files[0]) await replaceMarkerAudio(m.id, e.target.files[0]); };
replaceLabel.appendChild(replaceInput);
const nameSpan = document.createElement('span');
nameSpan.className = 'audio-name';
nameSpan.innerText = m.audioName ? (m.audioName.length > 40 ? m.audioName.slice(0,37)+'...' : m.audioName) : '(no audio)';
const previewBtn = document.createElement('button');
previewBtn.className = 'small';
previewBtn.innerText = '🔊';
previewBtn.style.background = '#2c5f2d';
previewBtn.onclick = () => {
if (m.audioBlob) {
const url = URL.createObjectURL(m.audioBlob);
const a = new Audio(url);
a.play().catch(e => alert("Cannot play"));
a.onended = () => URL.revokeObjectURL(url);
} else alert("No audio assigned");
};
const deleteBtn = document.createElement('button');
deleteBtn.className = 'small';
deleteBtn.innerText = '🗑️';
deleteBtn.style.background = '#3a2e2e';
deleteBtn.onclick = () => deleteMarker(m.id);
div.appendChild(timeInput);
div.appendChild(replaceLabel);
div.appendChild(nameSpan);
div.appendChild(previewBtn);
div.appendChild(deleteBtn);
markersContainer.appendChild(div);
});
}
function drawTimeline() {
if (!timelineCanvas || !videoDuration || videoDuration <= 0) return;
const canvas = timelineCanvas;
const ctx = canvas.getContext('2d');
const width = canvas.clientWidth;
canvas.width = width;
canvas.height = 70;
ctx.fillStyle = '#0f0f17';
ctx.fillRect(0, 0, width, 70);
ctx.strokeStyle = '#2a2a36';
for (let i = 0; i <= 10; i++) {
let x = (i / 10) * width;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, 70);
ctx.stroke();
}
for (const m of markers) {
const x = (m.timestamp / videoDuration) * width;
ctx.beginPath();
ctx.arc(x, 35, 7, 0, 2*Math.PI);
ctx.fillStyle = '#f59e0b';
ctx.fill();
ctx.fillStyle = '#ffc107';
ctx.arc(x, 35, 3, 0, 2*Math.PI);
ctx.fill();
ctx.font = "8px monospace";
ctx.fillStyle = "#aaa";
ctx.fillText(formatTime(m.timestamp), x-18, 58);
}
}
function updateRuler() {
if (!videoDuration) return;
const ruler = document.getElementById('timelineRuler');
ruler.innerHTML = '';
for (let i = 0; i <= 10; i++) {
const time = (i / 10) * videoDuration;
const span = document.createElement('span');
span.innerText = formatTime(time);
ruler.appendChild(span);
}
}
function updatePlayhead() {
if (!videoPlayer || !videoDuration) return;
const percent = (videoPlayer.currentTime / videoDuration) * 100;
playheadDiv.style.left = `${Math.min(100, Math.max(0, percent))}%`;
currentTimeDisplay.innerText = formatTime(videoPlayer.currentTime);
}
// SPECIAL PLAY - FIXED: NO AUDIO RESTART, each marker plays once when crossing timestamp
function startSpecialPlay() {
if (!videoPlayer.src) { alert("Upload video first"); return; }
if (syncActive) cancelSync();
syncActive = true;
videoPlayer.play();
// Track which markers have been triggered during this playback session
const triggeredMarkers = new Set();
activeInterval = setInterval(() => {
if (!syncActive || videoPlayer.paused || videoPlayer.ended) {
if (videoPlayer.paused || videoPlayer.ended) cancelSync();
return;
}
const ct = videoPlayer.currentTime;
for (const marker of markers) {
if (!marker.audioBlob) continue;
// Check if we should trigger: timestamp within 0.05 seconds AND not triggered yet
const shouldTrigger = Math.abs(marker.timestamp - ct) <= 0.05 && !triggeredMarkers.has(marker.id);
if (shouldTrigger) {
triggeredMarkers.add(marker.id);
const url = URL.createObjectURL(marker.audioBlob);
const audio = new Audio(url);
audio.volume = 1.0;
audio.play().catch(e => console.log("play error"));
audio.onended = () => URL.revokeObjectURL(url);
setTimeout(() => URL.revokeObjectURL(url), 3000);
}
}
}, 40);
// Reset triggered markers when seeking happens during special play
const seekHandler = () => {
// Clear triggered markers on seek so they can play again if user seeks back
triggeredMarkers.clear();
const ct = videoPlayer.currentTime;
for (const marker of markers) {
if (!marker.audioBlob) continue;
if (Math.abs(marker.timestamp - ct) <= 0.1 && !triggeredMarkers.has(marker.id)) {
triggeredMarkers.add(marker.id);
const url = URL.createObjectURL(marker.audioBlob);
const audio = new Audio(url);
audio.play().catch(e=>{});
audio.onended = () => URL.revokeObjectURL(url);
setTimeout(() => URL.revokeObjectURL(url), 3000);
}
}
};
videoPlayer.addEventListener('seeked', seekHandler);
// When video ends or pauses, cleanup
const onEnded = () => cancelSync();
videoPlayer.addEventListener('ended', onEnded, { once: true });
window.syncCleanup = () => {
clearInterval(activeInterval);
videoPlayer.removeEventListener('seeked', seekHandler);
videoPlayer.removeEventListener('ended', onEnded);
syncActive = false;
};
}
function cancelSync() {
if (activeInterval) clearInterval(activeInterval);
if (window.syncCleanup) window.syncCleanup();
syncActive = false;
document.querySelectorAll('.temp-sync-audio').forEach(a => { a.pause(); a.remove(); });
}
// YOUR PERFECT FFMPEG COMMAND GENERATION
function generateFfmpegCommand() {
if (!currentVideoFile) return "## No video loaded";
const videoFileName = currentVideoFile.name;
const videoPath = `"${videoFileName.replace(/"/g, '\\"')}"`;
const validMarkers = markers.filter(m => m.audioBlob && m.audioName);
const retainVideo = retainVideoAudioCheckbox.checked;
if (validMarkers.length === 0 && !retainVideo) return "# No audio to process";
// Build filter complex lines exactly like your working example
let filterLines = [];
let amixInputs = [];
// Video audio line (index 0)
if (retainVideo) {
filterLines.push(`[0:a]aresample=44100,volume=0.7[a0]`);
amixInputs.push(`[a0]`);
}
// Snippet lines
for (let i = 0; i < validMarkers.length; i++) {
const m = validMarkers[i];
const delayMs = Math.round(m.timestamp * 1000);
const snippetIndex = retainVideo ? i + 1 : i;
const filterLabel = `a${retainVideo ? i+1 : i}`;
filterLines.push(`[${i+1}:a]aresample=44100,adelay=${delayMs}|${delayMs},volume=2[a${retainVideo ? i+1 : i}]`);
amixInputs.push(`[a${retainVideo ? i+1 : i}]`);
}
const numInputs = amixInputs.length;
const amixLine = `${amixInputs.join('')}amix=inputs=${numInputs}:duration=longest[outa]`;
const filterComplex = filterLines.join('; \\\n') + '; \\\n' + amixLine;
// Build input files part
let inputs = ` -i ${videoPath}`;
for (const m of validMarkers) {
const audioPath = `"${m.audioName.replace(/"/g, '\\"')}"`;
inputs += ` \\\n -i ${audioPath}`;
}
const outputName = videoFileName.replace(/\.[^/.]+$/, '') + (retainVideo ? "_mixed.mp4" : "_snippets_only.mp4");
const outputPath = `"${outputName.replace(/"/g, '\\"')}"`;
let ffmpegCmd = `ffmpeg ${inputs} \\\n-filter_complex "\\\n${filterComplex}\\\n" \\\n-map 0:v -map "[outa]" \\\n-c:v copy -c:a aac -b:a 192k -shortest \\\n${outputPath}`;
// Add cd prefix if checkbox is checked
if (addCdPrefixCheckbox.checked) {
let cdPath = cdPathInput.value.trim();
if (cdPath) {
// Remove trailing slash if present
if (cdPath.endsWith('/')) cdPath = cdPath.slice(0, -1);
ffmpegCmd = `cd ${cdPath} && \\\n${ffmpegCmd}`;
}
}
return ffmpegCmd;
}
function updateFfmpegCommand() {
ffmpegCommandDisplay.innerText = generateFfmpegCommand();
}
// Export WAV mix (simple preview)
async function exportFullMix() {
if (!videoDuration) { alert("Load video first"); return; }
const valid = markers.filter(m => m.audioBlob);
if (valid.length === 0) { alert("No audio snippets"); return; }
const sampleRate = 44100;
const offlineCtx = new OfflineAudioContext({ numberOfChannels: 2, length: Math.ceil(videoDuration * sampleRate), sampleRate });
for (const m of valid) {
const buf = await m.audioBlob.arrayBuffer();
const audioBuf = await offlineCtx.decodeAudioData(buf);
const src = offlineCtx.createBufferSource();
src.buffer = audioBuf;
src.connect(offlineCtx.destination);
src.start(m.timestamp);
}
const rendered = await offlineCtx.startRendering();
const wav = bufferToWave(rendered);
const url = URL.createObjectURL(wav);
const a = document.createElement('a');
a.href = url;
a.download = `audio_export_${Date.now()}.wav`;
a.click();
URL.revokeObjectURL(url);
alert("Export complete!");
}
function bufferToWave(abuffer) {
const samples = abuffer.getChannelData(0);
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
function writeStr(offset, str) { for(let i=0;i<str.length;i++) view.setUint8(offset+i, str.charCodeAt(i)); }
writeStr(0, 'RIFF');
view.setUint32(4, 36 + samples.length * 2, true);
writeStr(8, 'WAVE');
writeStr(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 2, true);
view.setUint32(24, 44100, true);
view.setUint32(28, 44100 * 4, true);
view.setUint16(32, 4, true);
view.setUint16(34, 16, true);
writeStr(36, 'data');
view.setUint32(40, samples.length * 2, true);
let offset = 44;
for (let i = 0; i < samples.length; i++, offset += 2) {
let s = Math.max(-1, Math.min(1, samples[i]));
s = s < 0 ? s * 0x8000 : s * 0x7FFF;
view.setInt16(offset, s, true);
}
return new Blob([buffer], { type: 'audio/wav' });
}
function batchAutoAdd() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'audio/*';
input.multiple = true;
input.onchange = async (e) => {
const files = Array.from(e.target.files);
for (const file of files) {
const ts = extractTimestampFromFilename(file.name);
if (ts !== null && videoDuration && ts >= 0 && ts <= videoDuration) {
const blob = new Blob([await file.arrayBuffer()], { type: file.type });
await addMarker(ts, blob, file.name, true);
} else if (ts !== null) alert(`Timestamp ${formatTime(ts)} out of range`);
else alert(`No timestamp prefix: ${file.name}`);
}
};
input.click();
}
// Video upload - PRESERVES markers from querystring
videoUpload.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
currentVideoFile = file;
const url = URL.createObjectURL(file);
videoPlayer.src = url;
videoPlayer.load();
videoStatus.innerText = `${file.name}`;
cancelSync();
videoPlayer.addEventListener('loadedmetadata', () => {
videoDuration = videoPlayer.duration;
updateRuler();
drawTimeline();
updatePlayhead();
updateFfmpegCommand();
videoStatus.innerText += ` | ${formatTime(videoDuration)}`;
}, { once: true });
});
// Event listeners
videoPlayer.addEventListener('timeupdate', updatePlayhead);
specialPlayBtn.addEventListener('click', () => { cancelSync(); startSpecialPlay(); });
exportAudioBtn.addEventListener('click', exportFullMix);
addCurrentBtn.addEventListener('click', () => { if(videoDuration) addMarker(videoPlayer.currentTime); });
addManualBtn.addEventListener('click', () => {
const sec = parseTimestamp(manualTimestampInput.value);
if (isNaN(sec)) alert("Invalid timestamp. Use seconds like 12.5 or mm:ss");
else addMarker(sec);
manualTimestampInput.value = '';
});
batchAutoBtn.addEventListener('click', batchAutoAdd);
refreshFfmpegCmd.addEventListener('click', updateFfmpegCommand);
copyFfmpegBtn.addEventListener('click', () => {
navigator.clipboard.writeText(ffmpegCommandDisplay.innerText);
alert("Command copied!");
});
exportQueryBtn.addEventListener('click', () => {
if (markers.length === 0) { alert("Add markers first"); return; }
const url = exportMarkersToQueryString();
window.history.pushState({}, '', url);
updateShareUrlDisplay();
alert("URL updated! Share the link to preserve markers.");
});
copyUrlBtn.addEventListener('click', copyShareUrl);
clearMarkersBtn.addEventListener('click', clearAllMarkers);
retainVideoAudioCheckbox.addEventListener('change', updateFfmpegCommand);
addCdPrefixCheckbox.addEventListener('change', updateFfmpegCommand);
cdPathInput.addEventListener('input', updateFfmpegCommand);
timelineCanvas.addEventListener('click', (e) => {
if (!videoDuration) return;
const rect = timelineCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const percent = x / rect.width;
videoPlayer.currentTime = percent * videoDuration;
});
window.addEventListener('resize', () => drawTimeline());
// Initialize
importMarkersFromUrl();
renderMarkersList();
updateFfmpegCommand();
</script>
</body>
</html>