1018 lines
36 KiB
HTML
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 '&';
|
|
if (m === '<') return '<';
|
|
if (m === '>') return '>';
|
|
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>
|