Files
2026-05-13 16:11:20 +02:00

1018 lines
36 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=yes">
<title>Stream Overlay</title>
<style>
* {
box-sizing: border-box;
}
body {
background: linear-gradient(135deg, #0a0f1a 0%, #0c1122 100%);
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
margin: 0;
padding: 24px;
color: #eef5ff;
}
.app-container {
max-width: 1400px;
margin: 0 auto;
}
.header {
margin-bottom: 1.8rem;
display: flex;
justify-content: space-between;
align-items: baseline;
flex-wrap: wrap;
gap: 12px;
}
.grid-2col {
display: flex;
flex-wrap: wrap;
gap: 24px;
margin-bottom: 28px;
}
.upload-panel {
flex: 1.2;
min-width: 280px;
background: #0f1629aa;
backdrop-filter: blur(4px);
border-radius: 2rem;
padding: 1.2rem;
border: 1px solid #29344b;
}
.player-panel {
flex: 2.5;
min-width: 320px;
}
.file-row {
background: #0a0f1cd9;
margin: 12px 0;
padding: 12px 16px;
border-radius: 2rem;
}
.file-label {
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
font-size: 0.9rem;
}
input[type="file"] {
background: #1a2338;
border: 1px solid #3a4668;
padding: 8px 12px;
border-radius: 40px;
width: 100%;
color: white;
font-size: 0.8rem;
cursor: pointer;
}
.status {
font-size: 0.8rem;
margin-top: 6px;
color: #9aaec9;
}
.error-status {
color: #ff8888;
}
.video-wrapper {
position: relative;
background: black;
border-radius: 1.5rem;
overflow: hidden;
box-shadow: 0 20px 35px -12px black;
margin-bottom: 1rem;
}
video {
width: 100%;
display: block;
background: #000;
}
.now-playing-banner {
position: absolute;
bottom: 20px;
right: 24px;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(12px);
color: white;
padding: 12px 24px;
border-radius: 60px;
font-weight: 600;
font-size: 7px;
border-left: 5px solid #ffaa44;
box-shadow: 0 6px 18px rgba(0,0,0,0.7);
pointer-events: none;
z-index: 999;
white-space: nowrap;
font-family: monospace;
letter-spacing: 0.3px;
transition: opacity 0.2s ease;
max-width: 90vw;
overflow-x: auto;
opacity: 0.9;
}
.banner-hidden {
opacity: 0;
visibility: hidden;
}
.banner-visible {
opacity: 0.9;
visibility: visible;
}
.config-row {
display: flex;
flex-wrap: wrap;
gap: 18px;
margin: 12px 0;
justify-content: space-between;
}
.pos-control {
background: #0f172fb3;
padding: 6px 15px;
border-radius: 60px;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
select, input.number-input {
background: #1f2a48;
border: 1px solid #5e6e96;
border-radius: 30px;
padding: 4px 12px;
color: white;
}
button {
background: #2d4070;
border: none;
color: white;
font-weight: 600;
padding: 8px 20px;
border-radius: 40px;
cursor: pointer;
transition: 0.2s;
font-size: 0.8rem;
}
button.primary {
background: #e68a2e;
}
button.primary:hover {
background: #ff9f4a;
}
button.sample-btn {
background: #2d6a4f;
}
button.sample-btn:hover {
background: #40916c;
}
.info-panel {
background: #0b1122cc;
backdrop-filter: blur(8px);
border-radius: 1.5rem;
padding: 1rem;
margin-top: 1rem;
border: 1px solid #2c3857;
font-size: 0.8rem;
}
.chapters-container {
background: #0a0f1ccc;
border-radius: 1.2rem;
padding: 1rem;
margin-top: 1rem;
max-height: 280px;
overflow-y: auto;
}
.chapters-title {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
}
.chapter-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chapter-btn {
background: #1e2a4a;
padding: 6px 14px;
border-radius: 40px;
font-size: 0.9rem;
font-family: monospace;
transition: 0.1s;
cursor: pointer;
}
.chapter-btn:hover {
background: #3a5590;
transform: scale(0.96);
}
.filter-section {
background: #0f172fb3;
padding: 12px;
border-radius: 1rem;
margin-top: 12px;
}
.filter-input {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.filter-input input {
flex: 1;
background: #1a2338;
border: 1px solid #3a4668;
padding: 6px 12px;
border-radius: 30px;
color: white;
}
.filter-badge {
display: inline-block;
background: #2d4070;
padding: 2px 8px;
border-radius: 30px;
font-size: 0.9rem;
margin: 2px;
}
.ffmpeg-export {
background: #0b1122cc;
border-radius: 1.2rem;
padding: 1rem;
margin-top: 1rem;
}
.ffmpeg-command {
background: #050a17;
padding: 12px;
border-radius: 1rem;
font-family: monospace;
font-size: 0.9rem;
white-space: pre-wrap;
word-break: break-all;
margin-top: 8px;
}
.style-config {
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
margin-top: 8px;
}
.warning-note {
background: #2a2318;
border-left: 4px solid #ffb347;
padding: 8px 16px;
border-radius: 20px;
font-size: 0.7rem;
margin-top: 12px;
color: #ffddb0;
}
.burn-actions {
display: flex;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
align-items: center;
}
.output-dir {
background: #00000066;
padding: 6px 12px;
border-radius: 30px;
font-family: monospace;
font-size: 0.7rem;
}
.command-preview {
background: #0a0f1a;
padding: 8px;
border-radius: 12px;
font-family: monospace;
font-size: 0.7rem;
margin-top: 8px;
border: 1px solid #2d4070;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
background: #0f172fb3;
padding: 6px 15px;
border-radius: 60px;
cursor: pointer;
}
.checkbox-label input {
width: 18px;
height: 18px;
cursor: pointer;
}
@media (max-width: 760px) {
.now-playing-banner { font-size: 6px; padding: 6px 14px; white-space: normal; word-break: break-word; }
.chapter-btn { padding: 4px 10px; font-size: 0.65rem; }
}
</style>
</head>
<body>
<div class="app-container">
<div class="header">
<h2>Stream Overlay Tool</h2>
<div class="output-dir">📁 Output Dir: /storage/emulated/0/Movies/</div>
</div>
<div class="grid-2col">
<div class="upload-panel">
<div class="file-row">
<div class="file-label">🎥 MP4 Video</div>
<input type="file" id="videoFileInput" accept="video/mp4">
<div class="status" id="videoStatus">📁 No video loaded</div>
<div class="status" id="parsedStartTime" style="color: #ffaa66;">🔍 Filename start time: —</div>
</div>
<div class="file-row">
<div class="file-label">📋 Timestamp TXT (ex. [21.07][1778612845] Callisto - Ruzer Ringtones )</div>
<input type="file" id="txtFileInput" accept=".txt">
<div class="status" id="txtStatus">⏳ Waiting for .txt file</div>
<div class="status" id="filteredCountSpan">📋 Filtered entries: 0</div>
</div>
<div class="filter-section">
<div class="file-label">🚫 Filter keywords (remove from notification)</div>
<div class="filter-input">
<input type="text" id="keywordFilter" placeholder="e.g., Ringtones, .mp3, DANGER" value="Ringtones">
<button id="applyFilterBtn">Apply</button>
</div>
<div id="activeFilters" style="margin-top: 6px;"></div>
</div>
<div class="info-panel">
<strong>✅ Fixed:</strong> All special chars ((), [], $, etc.) properly escaped for bash.<br>
<strong>🎨 Banner:</strong> Uses FFmpeg's default font (no external dependency)<br>
<strong>📐 Scale:</strong> Check box to resize video to 1280x720
</div>
</div>
<div class="player-panel">
<div class="video-wrapper" id="videoWrapper">
<video id="mediaPlayer" controls preload="metadata"></video>
<div id="dynamicBanner" class="now-playing-banner banner-hidden">🎵 --</div>
</div>
<div class="config-row">
<div class="pos-control">
<span>📍 Banner pos:</span>
<select id="bannerPosX">
<option value="right" selected>Right</option>
<option value="left">Left</option>
<option value="center">Center X</option>
</select>
<select id="bannerPosY">
<option value="bottom" selected>Bottom</option>
<option value="top">Top</option>
</select>
<span>X/Y offset</span>
<input type="number" id="offsetX" value="24" step="2" style="width:65px">
<input type="number" id="offsetY" value="20" step="2" style="width:65px">
<button id="applyPosBtn">Apply</button>
</div>
<button id="testBannerBtn" style="background:#3a5588;">🔔 Test</button>
</div>
<div class="style-config">
<div class="pos-control">
<span>🎨 Font size:</span>
<input type="number" id="fontSizeInput" value="9" min="5" max="24" step="1" style="width:70px">
<span>px</span>
</div>
<div class="pos-control">
<span>🎨 Opacity:</span>
<input type="range" id="opacitySlider" min="0.3" max="1" step="0.01" value="0.9" style="width:100px">
<span id="opacityValue">70%</span>
</div>
<button id="applyStyleBtn">Apply</button>
</div>
<div class="pos-control" style="justify-content: space-between; flex-wrap: wrap;">
<div style="display: flex; gap: 12px; align-items: center;">
<span>⏱️ Banner duration:</span>
<input type="number" id="bannerDurationSec" value="2" min="0.5" max="10" step="0.5" style="width: 70px;">
<span>seconds</span>
</div>
<label class="checkbox-label">
<input type="checkbox" id="scaleCheckbox" checked>
<span>📐 Apply -vf "scale=1280:720" (resize to 720p)</span>
</label>
</div>
<div class="burn-actions">
<button id="burnSampleBtn" class="sample-btn">🔥 Burn Sample (first 2 timestamps)</button>
<button id="copyFullCommandBtn">📋 Copy Full FFmpeg</button>
</div>
<div id="sampleStatus" class="status"></div>
<div class="command-preview" id="sampleCommandPreview" style="display: none;">
<strong>📝 Sample Command (cd + burn first 2):</strong>
<pre id="sampleCommandText" style="margin: 5px 0 0 0; overflow-x: auto; font-size: 0.65rem;"></pre>
</div>
<div class="chapters-container" id="chaptersContainer" style="display: none;">
<div class="chapters-title">
<span>🎯 Skip to timestamp</span>
<span id="chapterCountBadge">0 entries</span>
</div>
<div class="chapter-buttons" id="chapterButtonsList"></div>
</div>
<div class="ffmpeg-export">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap;">
<strong>🔥 Full FFmpeg Command (all timestamps)</strong>
</div>
<div class="ffmpeg-command" id="ffmpegCommandDisplay"># Upload files to generate command</div>
</div>
</div>
</div>
</div>
<script>
// DOM Elements
const video = document.getElementById('mediaPlayer');
const videoWrapper = document.getElementById('videoWrapper');
const bannerDiv = document.getElementById('dynamicBanner');
const videoFileInput = document.getElementById('videoFileInput');
const txtFileInput = document.getElementById('txtFileInput');
const videoStatus = document.getElementById('videoStatus');
const txtStatus = document.getElementById('txtStatus');
const parsedStartTimeSpan = document.getElementById('parsedStartTime');
const filteredCountSpan = document.getElementById('filteredCountSpan');
const chaptersContainer = document.getElementById('chaptersContainer');
const chapterButtonsList = document.getElementById('chapterButtonsList');
const chapterCountBadge = document.getElementById('chapterCountBadge');
const bannerDurationInput = document.getElementById('bannerDurationSec');
const keywordFilterInput = document.getElementById('keywordFilter');
const applyFilterBtn = document.getElementById('applyFilterBtn');
const fontSizeInput = document.getElementById('fontSizeInput');
const opacitySlider = document.getElementById('opacitySlider');
const opacityValue = document.getElementById('opacityValue');
const applyStyleBtn = document.getElementById('applyStyleBtn');
const burnSampleBtn = document.getElementById('burnSampleBtn');
const copyFullCommandBtn = document.getElementById('copyFullCommandBtn');
const sampleStatus = document.getElementById('sampleStatus');
const sampleCommandPreview = document.getElementById('sampleCommandPreview');
const sampleCommandText = document.getElementById('sampleCommandText');
const scaleCheckbox = document.getElementById('scaleCheckbox');
// Data
let videoStartUnix = null;
let timelineEntries = [];
let filteredEntries = [];
let activeKeywords = [];
let bannerTimeout = null;
let currentVideoFile = null;
let currentVideoName = "input.mp4";
let lastTriggeredIndex = -1;
const OUTPUT_DIR = "/storage/emulated/0/Movies/";
// SHELL ESCAPE FUNCTION - critical for bash compatibility
function shellEscape(str) {
if (!str) return '';
// Replace ' with '\'' (close quote, escaped quote, open quote)
// Then wrap entire string in single quotes
return "'" + str.replace(/'/g, "'\\''") + "'";
}
// Escape for FFmpeg drawtext filter (different from shell)
function drawtextEscape(str) {
return str
.replace(/\\/g, '\\\\')
.replace(/'/g, "'\\\\''")
.replace(/:/g, '\\:')
replace(/\[/g, '\\[')
.replace(/\]/g, '\\]')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)')
.replace(/\$/g, '\\$')
.replace(/`/g, '\\`')
.replace(/"/g, '\\"');
}
function getScaleFilter() {
return scaleCheckbox.checked ? "scale=1280:720," : "";
}
function buildVideoFilterChain(drawtextFilters) {
const scalePart = getScaleFilter();
if (!drawtextFilters.length) return scalePart.replace(/,$/, '');
const drawtextChain = drawtextFilters.join(",");
if (scalePart) {
return scalePart + drawtextChain;
}
return drawtextChain;
}
function parseVideoStartFromFilename(filename) {
const match = filename.match(/(\d{4})-(\d{2})-(\d{2})\.(\d{2})\.(\d{2})\.(\d{2})/);
if (!match) return null;
const [_, year, month, day, hour, minute, second] = match;
const dateStr = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
const date = new Date(dateStr);
return isNaN(date.getTime()) ? null : Math.floor(date.getTime() / 1000);
}
function parseTimestampLine(line) {
if (line.charCodeAt(0) === 0xFEFF) {
line = line.slice(1);
}
const bracketMatches = [];
const bracketRegex = /\[(\d+(?:\.\d+)?)\]/g;
let match;
while ((match = bracketRegex.exec(line)) !== null) {
bracketMatches.push(match[1]);
}
if (bracketMatches.length < 2) return null;
const offsetSec = parseFloat(bracketMatches[0]);
const unixTimestamp = parseInt(bracketMatches[1], 10);
if (isNaN(offsetSec) || isNaN(unixTimestamp)) return null;
let textPart = line.replace(/\[\d+(?:\.\d+)?\]/g, '').trim();
if (textPart === "") return null;
return { offsetSec, unixTimestamp, text: textPart };
}
async function loadTxtContent(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
const lines = content.split(/\r?\n/);
const entries = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (!line) continue;
const parsed = parseTimestampLine(line);
if (parsed) entries.push(parsed);
}
entries.sort((a, b) => a.unixTimestamp - b.unixTimestamp);
resolve(entries);
};
reader.onerror = reject;
reader.readAsText(file, "UTF-8");
});
}
function updateKeywords() {
const raw = keywordFilterInput.value;
activeKeywords = raw.split(',').map(k => k.trim().toLowerCase()).filter(k => k.length > 0);
const filtersDiv = document.getElementById('activeFilters');
if (activeKeywords.length) {
filtersDiv.innerHTML = activeKeywords.map(k => `<span class="filter-badge">🚫 ${escapeHtml(k)}</span>`).join('');
} else {
filtersDiv.innerHTML = '<span style="font-size:0.9rem;">No active filters</span>';
}
}
function filterText(rawText) {
if (!activeKeywords.length) return rawText;
let filtered = rawText;
for (const kw of activeKeywords) {
const regex = new RegExp(kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
filtered = filtered.replace(regex, '');
}
filtered = filtered.replace(/\s+/g, ' ').trim();
return filtered || rawText;
}
function rebuildFilteredEntries() {
if (!timelineEntries.length) {
filteredEntries = [];
filteredCountSpan.innerHTML = `📋 Filtered entries: 0`;
txtStatus.innerHTML = `⏳ No entries loaded`;
return;
}
let entries = timelineEntries;
if (videoStartUnix) {
entries = entries.filter(e => e.unixTimestamp > videoStartUnix);
}
filteredEntries = entries.map(e => ({
...e,
displayText: filterText(e.text)
}));
const afterVideoMsg = videoStartUnix ? ` (after ${new Date(videoStartUnix*1000).toLocaleTimeString()})` : '';
filteredCountSpan.innerHTML = `📋 Filtered entries: ${filteredEntries.length}${afterVideoMsg}`;
txtStatus.innerHTML = `${timelineEntries.length} total → ${filteredEntries.length} after filter`;
txtStatus.classList.remove('error-status');
buildChapterButtons();
lastTriggeredIndex = -1;
updateFFmpegDisplay();
updateSamplePreview();
}
function applyBannerStyle() {
const fontSize = parseInt(fontSizeInput.value);
const opacity = parseFloat(opacitySlider.value);
bannerDiv.style.fontSize = fontSize + 'px';
bannerDiv.style.opacity = opacity;
opacityValue.innerText = Math.round(opacity * 100) + '%';
updateFFmpegDisplay();
updateSamplePreview();
}
function updateBannerPosition() {
const xOpt = document.getElementById('bannerPosX').value;
const yOpt = document.getElementById('bannerPosY').value;
let offX = parseInt(document.getElementById('offsetX').value, 10);
let offY = parseInt(document.getElementById('offsetY').value, 10);
if (isNaN(offX)) offX = 24;
if (isNaN(offY)) offY = 20;
bannerDiv.style.left = '';
bannerDiv.style.right = '';
bannerDiv.style.top = '';
bannerDiv.style.bottom = '';
bannerDiv.style.transform = '';
if (xOpt === 'right') bannerDiv.style.right = offX + 'px';
else if (xOpt === 'left') bannerDiv.style.left = offX + 'px';
else if (xOpt === 'center') {
bannerDiv.style.left = '50%';
bannerDiv.style.transform = 'translateX(-50%)';
}
if (yOpt === 'bottom') bannerDiv.style.bottom = offY + 'px';
else bannerDiv.style.top = offY + 'px';
updateFFmpegDisplay();
updateSamplePreview();
}
function showBanner(text, durationSeconds = null) {
if (bannerTimeout) clearTimeout(bannerTimeout);
const displayText = filterText(text);
if (!displayText || displayText.length === 0) return;
const duration = durationSeconds !== null ? durationSeconds : parseFloat(bannerDurationInput.value);
bannerDiv.innerHTML = `<span>🎵 ${escapeHtml(displayText)}</span>`;
bannerDiv.classList.remove('banner-hidden');
bannerDiv.classList.add('banner-visible');
bannerTimeout = setTimeout(() => {
bannerDiv.classList.remove('banner-visible');
bannerDiv.classList.add('banner-hidden');
}, duration * 1000);
}
function checkAndTriggerBanner() {
if (!videoStartUnix || filteredEntries.length === 0) return;
const currentAbsTime = videoStartUnix + video.currentTime;
let shouldTriggerIndex = -1;
for (let i = 0; i < filteredEntries.length; i++) {
if (currentAbsTime >= filteredEntries[i].unixTimestamp && i > lastTriggeredIndex) {
shouldTriggerIndex = i;
}
}
if (shouldTriggerIndex !== -1) {
showBanner(filteredEntries[shouldTriggerIndex].displayText);
lastTriggeredIndex = shouldTriggerIndex;
highlightChapterButton(shouldTriggerIndex);
}
}
function highlightChapterButton(index) {
const btns = document.querySelectorAll('.chapter-btn');
btns.forEach((btn, i) => {
if (i === index) {
btn.style.background = '#e68a2e';
setTimeout(() => { if (btn) btn.style.background = '#2d4070'; }, 500);
}
});
}
function jumpToEntry(entryIndex) {
if (!videoStartUnix || !filteredEntries[entryIndex]) return;
const targetVideoTime = filteredEntries[entryIndex].unixTimestamp - videoStartUnix;
if (targetVideoTime >= 0 && targetVideoTime <= video.duration) {
video.currentTime = targetVideoTime;
showBanner(filteredEntries[entryIndex].displayText);
lastTriggeredIndex = entryIndex;
if (video.paused) video.play().catch(e => console.log("autoplay blocked"));
} else {
showBanner(`⚠️ Time out of range`, 1.5);
}
}
function buildChapterButtons() {
if (filteredEntries.length === 0) {
chaptersContainer.style.display = 'none';
return;
}
chaptersContainer.style.display = 'block';
chapterCountBadge.innerText = `${filteredEntries.length} entries`;
chapterButtonsList.innerHTML = '';
filteredEntries.forEach((entry, idx) => {
const date = new Date(entry.unixTimestamp * 1000);
const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const shortText = entry.displayText.length > 30 ? entry.displayText.substring(0, 27) + '...' : entry.displayText;
const btn = document.createElement('button');
btn.className = 'chapter-btn';
btn.innerHTML = `🕐 ${timeStr}<br><span style="font-size:0.6rem;">${escapeHtml(shortText)}</span>`;
btn.title = entry.text;
btn.addEventListener('click', () => jumpToEntry(idx));
chapterButtonsList.appendChild(btn);
});
}
// Generate drawtext filters with PROPER escaping for FFmpeg
function generateDrawtextFilters(entries) {
const xOpt = document.getElementById('bannerPosX').value;
const offX = parseInt(document.getElementById('offsetX').value, 10) || 24;
const yOpt = document.getElementById('bannerPosY').value;
const offY = parseInt(document.getElementById('offsetY').value, 10) || 20;
const fontSize = parseInt(fontSizeInput.value) || 7;
const opacityVal = parseFloat(opacitySlider.value) || 0.9;
let xExpr = `w-tw-${offX}`;
if (xOpt === 'left') xExpr = `${offX}`;
else if (xOpt === 'center') xExpr = `(w-tw)/2`;
let yExpr = `h-th-${offY}`;
if (yOpt === 'top') yExpr = `${offY}`;
const alphaHex = Math.round(opacityVal * 255).toString(16).padStart(2, '0');
const filters = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const start = entry.unixTimestamp - videoStartUnix;
const end = (i+1 < entries.length) ? (entries[i+1].unixTimestamp - videoStartUnix) : 999999;
if (start < 0) continue;
// CRITICAL: Escape for drawtext filter
const safeText = drawtextEscape(entry.displayText);
const filter = `drawtext=text='${safeText}':fontcolor=white@0x${alphaHex}:fontsize=${fontSize}:box=1:boxcolor=black@0.9:boxborderw=6:x=${xExpr}:y=${yExpr}:enable='between(t,${start},${end})'`;
filters.push(filter);
}
return filters;
}
// Generate FULL command with PROPER shell escaping
function generateFullFFmpegCommand() {
if (!filteredEntries.length) return "# No valid entries after filtering";
if (!videoStartUnix) return "# Need video with valid filename timestamp";
const videoFileName = currentVideoName;
const outputFileName = videoFileName.replace(/\.mp4$/i, "_burned.mp4") || "output_burned.mp4";
const drawtextFilters = generateDrawtextFilters(filteredEntries);
if (!drawtextFilters.length) return "# No valid time ranges";
const videoFilter = buildVideoFilterChain(drawtextFilters);
// Build command with proper shell escaping
const cdPart = `cd ${OUTPUT_DIR}`;
const ffmpegCmd = `ffmpeg -i ${shellEscape(videoFileName)} -vf ${shellEscape(videoFilter)} -c:v libx264 -preset medium -crf 22 -c:a copy ${shellEscape(outputFileName)}`;
return `${cdPart} && ${ffmpegCmd}`;
}
// Generate SAMPLE command
function generateSampleCommand() {
if (!filteredEntries.length || filteredEntries.length < 2) {
return "# Need at least 2 filtered entries for sample burn";
}
if (!videoStartUnix) return "# Need video with valid filename timestamp";
const sampleEntries = filteredEntries.slice(0, 2);
const videoFileName = currentVideoName;
const baseName = videoFileName.replace(/\.mp4$/i, "");
const outputFileName = `${baseName}_SAMPLE_first2.mp4`;
const drawtextFilters = generateDrawtextFilters(sampleEntries);
if (!drawtextFilters.length) return "# Sample filter generation failed";
const videoFilter = buildVideoFilterChain(drawtextFilters);
const cdPart = `cd ${OUTPUT_DIR}`;
const ffmpegCmd = `ffmpeg -i ${shellEscape(videoFileName)} -vf ${shellEscape(videoFilter)} -c:v libx264 -preset medium -crf 22 -c:a copy ${shellEscape(outputFileName)}`;
return `${cdPart} && ${ffmpegCmd}`;
}
function updateFFmpegDisplay() {
document.getElementById('ffmpegCommandDisplay').innerText = generateFullFFmpegCommand();
}
function updateSamplePreview() {
const sampleCmd = generateSampleCommand();
if (sampleCmd.startsWith("#")) {
sampleCommandPreview.style.display = 'none';
return;
}
sampleCommandPreview.style.display = 'block';
sampleCommandText.innerText = sampleCmd;
}
function escapeHtml(str) {
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
// Event Handlers
videoFileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
currentVideoFile = file;
currentVideoName = file.name;
videoStatus.innerHTML = `🎬 ${file.name}`;
const url = URL.createObjectURL(file);
video.src = url;
video.load();
videoStartUnix = null;
lastTriggeredIndex = -1;
video.onloadedmetadata = () => {
const startSec = parseVideoStartFromFilename(file.name);
if (startSec) {
videoStartUnix = startSec;
const startDate = new Date(startSec * 1000);
parsedStartTimeSpan.innerHTML = `📅 ${startDate.toLocaleString()}`;
if (timelineEntries.length) rebuildFilteredEntries();
updateSamplePreview();
} else {
parsedStartTimeSpan.innerHTML = `⚠️ Could not parse filename (need IPS_YYYY-MM-DD.HH.MM.SS.xxx)`;
}
updateFFmpegDisplay();
};
video.ontimeupdate = checkAndTriggerBanner;
});
txtFileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
txtStatus.innerHTML = "⏳ Parsing...";
txtStatus.classList.remove('error-status');
try {
const entries = await loadTxtContent(file);
if (entries.length === 0) {
txtStatus.innerHTML = "❌ No valid timestamps found. Format: [20.48][1778611699] Description";
txtStatus.classList.add('error-status');
timelineEntries = [];
filteredEntries = [];
filteredCountSpan.innerHTML = "📋 Filtered entries: 0";
return;
}
timelineEntries = entries;
rebuildFilteredEntries();
updateFFmpegDisplay();
updateSamplePreview();
txtStatus.innerHTML = `✅ Loaded ${entries.length} entries`;
} catch(err) {
txtStatus.innerHTML = `❌ Error: ${err.message}`;
txtStatus.classList.add('error-status');
}
});
applyFilterBtn.addEventListener('click', () => {
updateKeywords();
if (timelineEntries.length) {
rebuildFilteredEntries();
updateSamplePreview();
}
});
applyStyleBtn.addEventListener('click', () => {
applyBannerStyle();
updateFFmpegDisplay();
updateSamplePreview();
});
document.getElementById('applyPosBtn').addEventListener('click', () => {
updateBannerPosition();
updateFFmpegDisplay();
updateSamplePreview();
});
document.getElementById('offsetX').addEventListener('change', () => {
updateBannerPosition();
updateSamplePreview();
});
document.getElementById('offsetY').addEventListener('change', () => {
updateBannerPosition();
updateSamplePreview();
});
document.getElementById('bannerPosX').addEventListener('change', () => {
updateBannerPosition();
updateSamplePreview();
});
document.getElementById('bannerPosY').addEventListener('change', () => {
updateBannerPosition();
updateSamplePreview();
});
opacitySlider.addEventListener('input', () => {
opacityValue.innerText = Math.round(opacitySlider.value * 100) + '%';
applyBannerStyle();
updateSamplePreview();
});
fontSizeInput.addEventListener('change', () => {
applyBannerStyle();
updateSamplePreview();
});
scaleCheckbox.addEventListener('change', () => {
updateFFmpegDisplay();
updateSamplePreview();
const statusMsg = scaleCheckbox.checked ? "Scale to 1280x720 enabled" : "Scale disabled";
sampleStatus.innerHTML = `📐 ${statusMsg}`;
setTimeout(() => { if (sampleStatus.innerHTML === `📐 ${statusMsg}`) sampleStatus.innerHTML = ""; }, 1500);
});
document.getElementById('testBannerBtn').addEventListener('click', () => {
showBanner('TEST BANNER — keyword filtering active');
});
burnSampleBtn.addEventListener('click', () => {
const sampleCmd = generateSampleCommand();
if (sampleCmd.startsWith("#")) {
sampleStatus.innerHTML = `${sampleCmd}`;
sampleStatus.style.color = "#ff8888";
setTimeout(() => { sampleStatus.innerHTML = ""; }, 3000);
return;
}
navigator.clipboard.writeText(sampleCmd).then(() => {
sampleStatus.innerHTML = `✅ Sample command copied! Run in terminal (cd to ${OUTPUT_DIR} first)`;
sampleStatus.style.color = "#aaffaa";
setTimeout(() => { sampleStatus.innerHTML = ""; }, 4000);
}).catch(() => {
sampleStatus.innerHTML = "⚠️ Manual copy needed";
});
});
copyFullCommandBtn.addEventListener('click', () => {
const fullCmd = generateFullFFmpegCommand();
navigator.clipboard.writeText(fullCmd).then(() => {
sampleStatus.innerHTML = `✅ Full command copied!`;
sampleStatus.style.color = "#aaffaa";
setTimeout(() => { sampleStatus.innerHTML = ""; }, 2000);
});
});
// Initialize
updateKeywords();
applyBannerStyle();
updateBannerPosition();
console.log("IPS Ready — All special chars properly escaped for bash!");
</script>
</body>
</html>