'use strict'; /** * tools/demo-video/record.js: nimmt das Demo-Video als echte Bewegung auf (30 Bilder pro Sekunde): Mauszeiger, Tippen, Klicken, Scrollen, * Hervorhebungen, weiche Übergänge und animierte Bildunterschriften, erst im Browser (mit der Demo-Installation), dann im Windows-Programm. * Nebenbei schreibt es .events.json (Zeitpunkte von Kapiteln, Klicks, Tastenanschlägen, Übergängen). make_music.js erzeugt daraus * Musik und Geräusche, die genau zum Bild passen. * 1. php tools/demo-video/seed_demo.php 2. php -S 127.0.0.1:8097 -t & * 3. electron tools/demo-video/record.js 4. bash tools/demo-video/compose.sh */ const { app, BrowserWindow } = require('electron'); const { spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const OUT = path.resolve(process.argv[2] || 'raw.mp4'); const BASE = 'http://127.0.0.1:8097'; const MOCK = path.resolve(__dirname, '..', '..', '..', 'desktop', 'test', 'mock.html'); const W = 1440, H = 900, FPS = 30; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); app.disableHardwareAcceleration(); // ── Gerüst, das in jede Seite eingesetzt wird: Zeiger, Klick-Welle, Hervorhebung, Bildunterschrift, Überblendung ── const OVERLAY = `(() => { if (window.__ov) return; window.__ov = true; const st = document.createElement('style'); st.textContent = \` #__cur{position:fixed;left:0;top:0;width:30px;height:30px;z-index:2147483647;pointer-events:none;filter:drop-shadow(0 2px 3px rgba(0,0,0,.45))} #__rip{position:fixed;left:0;top:0;width:44px;height:44px;margin:-22px 0 0 -22px;border-radius:50%;background:rgba(14,138,201,.35);border:3px solid #0E8AC9;z-index:2147483646;pointer-events:none;opacity:0} #__spot{position:fixed;z-index:2147483640;border-radius:16px;box-shadow:0 0 0 9999px rgba(6,18,28,.58),0 0 0 3px rgba(255,255,255,.92),0 0 28px 6px rgba(18,165,148,.55);transition:all .6s cubic-bezier(.2,.8,.2,1);opacity:0;pointer-events:none} #__cap{position:fixed;left:50%;bottom:34px;transform:translate(-50%,40px);opacity:0;transition:opacity .5s ease,transform .5s cubic-bezier(.2,.8,.2,1);z-index:2147483645;display:flex;align-items:center;gap:14px;background:rgba(8,24,36,.94);color:#fff;border-radius:50px;padding:12px 30px 12px 12px;box-shadow:0 14px 36px rgba(0,0,0,.45);font:600 22px 'Segoe UI',Arial,sans-serif;max-width:1260px} #__cap.on{opacity:1;transform:translate(-50%,0)} #__cap .n{flex:none;background:linear-gradient(135deg,#0E7FBF,#12A594);width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:19px} #__cap small{display:block;font-weight:400;font-size:15px;opacity:.78;margin-top:2px} #__cover{position:fixed;left:0;top:0;right:0;bottom:0;background:#0b1f2e;z-index:2147483647;opacity:1;transition:opacity .45s ease;pointer-events:none} html{scroll-behavior:auto!important}\`; document.documentElement.appendChild(st); const cur = document.createElement('div'); cur.id = '__cur'; cur.innerHTML = ''; const rip = document.createElement('div'); rip.id = '__rip'; const spot = document.createElement('div'); spot.id = '__spot'; const cap = document.createElement('div'); cap.id = '__cap'; const cover = document.createElement('div'); cover.id = '__cover'; document.documentElement.append(spot, cur, rip, cap, cover); window.__pos = window.__pos || { x: 700, y: 520 }; const place = (x, y) => { cur.style.transform = 'translate(' + x + 'px,' + y + 'px)'; }; place(window.__pos.x, window.__pos.y); window.__move = (x, y, ms) => new Promise((res) => { const from = { ...window.__pos }, t0 = performance.now(); const f = (t) => { const k = Math.min(1, (t - t0) / ms), e = k < .5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2; const lift = Math.sin(k * Math.PI) * 18; place(from.x + (x - from.x) * e, from.y + (y - from.y) * e - lift); if (k < 1) requestAnimationFrame(f); else { window.__pos = { x, y }; place(x, y); res(); } }; requestAnimationFrame(f); }); window.__ripple = (x, y) => { rip.style.left = x + 'px'; rip.style.top = y + 'px'; rip.animate([{ opacity: .9, transform: 'scale(.3)' }, { opacity: 0, transform: 'scale(1.6)' }], { duration: 520, easing: 'ease-out' }); }; window.__scrollTo = (el, y, ms) => new Promise((res) => { const from = el.scrollTop, t0 = performance.now(); const f = (t) => { const k = Math.min(1, (t - t0) / ms), e = k < .5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; el.scrollTop = from + (y - from) * e; if (k < 1) requestAnimationFrame(f); else res(); }; requestAnimationFrame(f); }); window.__caption = (n, text, sub) => { cap.innerHTML = '
' + n + '
' + text + (sub ? '' + sub + '' : '') + '
'; requestAnimationFrame(() => cap.classList.add('on')); }; window.__hideCaption = () => cap.classList.remove('on'); window.__spot = (x, y, w, h) => { spot.style.left = x + 'px'; spot.style.top = y + 'px'; spot.style.width = w + 'px'; spot.style.height = h + 'px'; spot.style.opacity = 1; }; window.__spotOff = () => { spot.style.opacity = 0; }; window.__cover = (on) => { cover.style.opacity = on ? 1 : 0; }; })()`; let win, ff, latest = null, lastBuf = null, size = null, written = 0, t0 = 0, pump = null; let step = 0; const events = []; const evt = (type, extra) => { if (t0) events.push({ t: +((Date.now() - t0) / 1000).toFixed(3), type, ...(extra || {}) }); }; const ev = async (js) => { try { return await win.webContents.executeJavaScript(js, true); } catch (e) { console.log('Fehler im Skript:', String(e).slice(0, 160), '\n ', js.slice(0, 120)); return null; } }; function startRecording() { ff = spawn('ffmpeg', ['-loglevel', 'error', '-y', '-f', 'rawvideo', '-pix_fmt', 'bgra', '-s', `${size.width}x${size.height}`, '-r', String(FPS), '-i', '-', '-vf', `scale=${W}:${H}:flags=lanczos`, '-c:v', 'libx264', '-crf', '15', '-preset', 'fast', '-pix_fmt', 'yuv420p', OUT], { stdio: ['pipe', 'inherit', 'inherit'] }); t0 = Date.now(); written = 0; pump = setInterval(() => { if (!latest) return; if (latest !== lastBuf) { lastBuf = latest; lastBuf.buf = latest.toBitmap(); } const target = Math.floor(((Date.now() - t0) * FPS) / 1000); while (written < target && ff.stdin.writableLength < 150e6) { ff.stdin.write(lastBuf.buf); written += 1; } }, 8); } async function stopRecording() { clearInterval(pump); await new Promise((r) => ff.stdin.end(r)); await new Promise((r) => ff.on('close', r)); } // ── Bausteine für Szenen ── const find = (spec) => `(() => { const s = ${JSON.stringify(spec)}; if (typeof s === 'string') return document.querySelector(s); return [...document.querySelectorAll(s.sel)].find((e) => (e.textContent || '').toLowerCase().includes(s.text.toLowerCase()) && e.getBoundingClientRect().width > 0 && (e.innerText || '').trim().length < 400); })()`; const center = async (spec) => ev(`(() => { const el = ${find(spec)}; if (!el) return null; el.scrollIntoView({ block: 'nearest' }); const r = el.getBoundingClientRect(); return { x: r.left + Math.min(r.width / 2, 120), y: r.top + r.height / 2 }; })()`); async function move(spec, ms = 800) { const c = await center(spec); if (!c) { console.log('Nicht gefunden:', JSON.stringify(spec)); return null; } await ev(`window.__move(${c.x}, ${c.y}, ${ms})`); return c; } async function click(spec, { wait = 400, ms = 800 } = {}) { const c = await move(spec, ms); if (!c) return; await ev(`window.__ripple(${c.x}, ${c.y})`); evt('click'); await sleep(150); await ev(`(() => { const el = ${find(spec)}; if (el) el.click(); })()`); await sleep(wait); } async function type(spec, text, per = 55) { await click(spec, { wait: 120, ms: 650 }); for (const ch of text) { await ev(`(() => { const el = ${find(spec)}; el.value += ${JSON.stringify(ch)}; el.dispatchEvent(new Event('input', { bubbles: true })); })()`); evt('key'); await sleep(per + Math.random() * 35); } await sleep(200); } async function scroll(selector, y, ms = 1300) { await ev(`window.__scrollTo(${selector === 'page' ? 'document.scrollingElement' : `document.querySelector(${JSON.stringify(selector)})`}, ${y}, ${ms})`); await sleep(150); } async function caption(text, sub) { step += 1; evt('caption'); await ev(`window.__caption(${step}, ${JSON.stringify(text)}, ${JSON.stringify(sub || '')})`); } const hide = async () => { await ev(`window.__hideCaption()`); await sleep(420); }; /** Element (oder der übergeordnete Kasten) hervorheben; der Rest der Seite wird abgedunkelt. */ async function spot(spec, { closest = '', pad = 12 } = {}) { const r = await ev(`(() => { let el = ${find(spec)}; if (!el) return null; ${closest ? `el = el.closest(${JSON.stringify(closest)}) || el;` : ''} el.scrollIntoView({ block: 'center' }); const b = el.getBoundingClientRect(); return { x: b.left, y: b.top, w: b.width, h: b.height }; })()`); if (!r) { console.log('Hervorhebung: nicht gefunden', JSON.stringify(spec)); return; } evt('pop'); await ev(`window.__spot(${r.x - pad}, ${r.y - pad}, ${r.w + 2 * pad}, ${r.h + 2 * pad})`); } const unspot = async () => { await ev(`window.__spotOff()`); await sleep(500); }; const chapter = (name) => evt('chapter', { name }); const CLEAN = `(() => { document.querySelectorAll('div,p,section').forEach((e) => { if (e.children.length < 6 && /SQLite-Modus aktiv/.test(e.textContent) && e.textContent.length < 260) e.remove(); }); document.getElementById('session-timer-bar')?.remove(); })()`; const inject = async () => { await ev(OVERLAY); await ev(CLEAN); }; /** Seite laden: erst überblenden, dann laden, Gerüst einsetzen, wieder aufblenden. */ async function go(url, opts = {}) { if (win.webContents.getURL() && !opts.first) { evt('whoosh'); await ev(`window.__cover && window.__cover(true)`); await sleep(480); } const loaded = new Promise((r) => win.webContents.once('did-finish-load', r)); if (opts.file) win.loadFile(opts.file); else win.loadURL(opts.raw ? url : BASE + url); await loaded; await sleep(120); await inject(); await sleep(250); await ev(`window.__cover(false)`); await sleep(520); } const waitLoad = () => new Promise((r) => win.webContents.once('did-finish-load', r)); async function nav(clickFn) { const l = waitLoad(); await clickFn(); evt('whoosh'); await l; await sleep(120); await inject(); await ev(`window.__cover(false)`); await sleep(450); } const card = (html, css = '') => 'data:text/html;charset=utf-8,' + encodeURIComponent(`
${html}
`); app.whenReady().then(async () => { win = new BrowserWindow({ width: W, height: H, show: false, useContentSize: true, webPreferences: { offscreen: true, contextIsolation: false, nodeIntegration: false, backgroundThrottling: false } }); win.webContents.setFrameRate(FPS); win.webContents.on('paint', (e, dirty, image) => { latest = image; if (!size) size = image.getSize(); }); win.webContents.on('console-message', () => {}); await win.loadURL(card('')); await sleep(500); startRecording(); try { // ── Titel ── chapter('intro'); await win.loadURL(card('
Das Helpdesk-System zum Selbsthosten
Kundenportal
Digitaler Assistent
Windows
Android
')); await sleep(3300); // ── 1. Kundenportal und digitaler Assistent ── chapter('portal'); await go('/home.php', { first: true }); await caption('Kundenportal', 'Hilfe finden und Tickets verfolgen'); await sleep(700); await scroll('page', 360, 1400); await scroll('page', 0, 1000); await hide(); await click('#as-bubble', { wait: 900 }); await caption('Digitaler Assistent', 'Antwortet aus Wissensdatenbank und eigenen Antworten'); await type('#as-input', 'Wie lange dauert der Versand?'); await click('#as-send'); await sleep(1100); evt('ding'); await sleep(1400); await click({ sel: '.as-btns button', text: 'Das hat geholfen' }, { wait: 800 }); await type('#as-input', 'Wie kann ich meinen Vertrag beenden?'); await click('#as-send'); await sleep(1500); await hide(); await caption('Keine Antwort gefunden?', 'Der Assistent bietet an, ein Ticket zu erstellen'); await sleep(900); await click({ sel: '.as-btns button', text: 'Ticket erstellen' }, { wait: 1500 }); await hide(); // ── 2. Mitarbeiterbereich ── chapter('staff'); await go('/staff/login.php'); await caption('Mitarbeiterbereich', 'Anmelden mit dem eigenen Konto'); await type('#email', 'lena@demo.example', 45); await type('#password', 'Demo-Passwort-1234', 45); await nav(() => click('form button[type=submit]', { wait: 100 })); await hide(); await go('/staff/dashboard.php'); await caption('Übersicht', 'Kennzahlen, Status des Teams und Berichte'); await sleep(1200); await scroll('page', 420, 1600); await sleep(500); await scroll('page', 0, 1200); await hide(); await nav(() => click({ sel: '.sidebar a, nav a, aside a', text: 'Tickets' }, { wait: 100 })); await caption('Tickets', 'Filter, Tabs und gespeicherte Ansichten'); await sleep(1400); await nav(() => click({ sel: 'a', text: 'Wiedervorlage' }, { wait: 100 })); await sleep(1100); await nav(() => click({ sel: 'a', text: 'Offen' }, { wait: 100 })); await sleep(700); await hide(); await go('/staff/ticket_view.php?n=260102'); await caption('Ticket bearbeiten', 'Verlauf, Antworten und interne Notizen'); await sleep(1500); await scroll('page', 700, 1500); await hide(); await caption('Wiedervorlage und Beobachter', 'Später wieder vorlegen, passende Artikel dazu'); await sleep(500); await spot({ sel: 'span', text: 'Wiedervorlage' }, { closest: '.card', pad: 8 }); await sleep(2200); await unspot(); await ev(`(() => { const s = document.querySelector('select[name=snooze_choice]'); if (s) s.value = '3h'; })()`); await nav(() => click({ sel: 'button', text: 'Auf später legen' }, { wait: 100 })); await sleep(1500); await hide(); // ── 3. Digitaler Assistent: Verwaltung ── chapter('assistant'); await go('/staff/assistant.php'); await caption('Assistenten verwalten', 'Einstellungen, Antworten, Fragen-Log'); await sleep(1300); await nav(() => click({ sel: 'a', text: 'Zusätzliche Antworten' }, { wait: 100 })); await sleep(1100); await nav(() => click({ sel: 'a', text: 'Fragen-Log' }, { wait: 100 })); await sleep(500); await hide(); await caption('Was blieb ohne Antwort?', 'Mit einem Klick eine neue Antwort anlegen'); await spot({ sel: 'b, strong', text: 'Häufige Fragen ohne Antwort' }, { closest: 'div', pad: 10 }); await sleep(2300); await unspot(); await nav(() => click({ sel: 'a', text: 'Keine Antwort' }, { wait: 100 })); await sleep(1400); await hide(); // ── 3b. Weitere Funktionen der Weboberfläche ── chapter('features'); const tour = async (url, title, sub, { y = 0, dwell = 2600 } = {}) => { await go(url); await caption(title, sub); await sleep(1100); if (y) { await scroll('page', y, 1700); await sleep(900); await scroll('page', 0, 1000); } else await sleep(dwell); await hide(); }; await tour('/staff/tickets.php', 'Alle Tickets', 'Suchen, filtern, sortieren und Sammelaktionen', { dwell: 3200 }); await tour('/staff/reports.php', 'Berichte', 'Auslastung, Antwortzeiten und Zufriedenheit', { y: 520 }); await tour('/staff/sla_plans.php', 'SLA-Pläne', 'Reaktions- und Lösungszeiten je Priorität', { dwell: 3000 }); await tour('/staff/escalation_rules.php', 'Eskalation und Regeln', 'Überfällige Tickets automatisch weitergeben', { dwell: 3000 }); await tour('/staff/canned_responses.php', 'Textbausteine', 'Fertige Antworten für wiederkehrende Fragen', { dwell: 3000 }); await tour('/staff/kb_manage.php', 'Wissensdatenbank', 'Artikel für Kunden und den Assistenten', { dwell: 3000 }); await tour('/staff/customers.php', 'Kunden und Organisationen', 'Alle Tickets eines Kunden auf einen Blick', { dwell: 3000 }); await tour('/staff/tasks.php', 'Aufgaben', 'Eigene To-dos neben den Tickets', { dwell: 2800 }); await tour('/staff/time_entries.php', 'Zeiterfassung', 'Aufwand pro Ticket und Kunde', { dwell: 2800 }); await tour('/staff/status_page.php', 'Status-Seite', 'Störungen öffentlich melden', { dwell: 3000 }); await tour('/staff/departments.php', 'Abteilungen, Teams und Rollen', 'Rechte genau festlegen', { dwell: 2800 }); await tour('/staff/api_keys.php', 'Schnittstelle und Webhooks', 'DeskHub mit anderen Systemen verbinden', { dwell: 2800 }); await tour('/staff/audit_log.php', 'Protokoll', 'Wer hat wann was geändert', { dwell: 2800 }); await tour('/staff/security_check.php', 'Sicherheitsprüfung', 'Zeigt Schwachstellen der Installation', { dwell: 3000 }); await tour('/staff/backup.php', 'Datensicherung', 'Backups per Klick, auch auf ein NAS', { dwell: 3200 }); await tour('/staff/plugins.php', 'Plugins', 'Erweiterungen ohne Eingriff am Server', { dwell: 3000 }); await tour('/staff/apps.php', 'Apps und Minecraft-Plugin', 'Windows, Android und Minecraft zum Herunterladen', { dwell: 3200 }); await tour('/staff/settings.php', 'Einstellungen', 'Name, Farben, E-Mail, Sicherheit und mehr', { y: 500 }); // ── 4. Windows-Programm ── chapter('windows'); await go('', { file: MOCK }); await ev(`(() => { const o = window.deskhub.api; window.deskhub.api = async (r) => { const res = await o(r); return res.error && /^Mock:/.test(res.error.message) ? { data: { items: [], rules: [], holidays: [], hours: [], staffOptions: [], departments: [], permissionKeys: [], fieldOptions: [], operatorOptions: [], actionOptions: [] } } : res; }; go('dashboard'); })()`); await sleep(900); await caption('Windows-Programm', 'Dieselben Funktionen auf dem Desktop'); await sleep(1800); await click({ sel: '.side .nav, .side button, .side a', text: 'Tickets' }, { wait: 1200 }); await click({ sel: 'tr.click, tr', text: 'Drucker druckt nicht' }, { wait: 1500 }); await hide(); await caption('Auf später legen und beobachten', 'Auch im Windows-Programm'); await click({ sel: 'button', text: 'Auf später' }, { wait: 1500 }); await ev(`document.querySelector('.modal-bg')?.remove()`); await ev(`state.staff.role = 'admin'; go('admin', 'assistant')`); evt('whoosh'); await sleep(1000); await hide(); await caption('Assistent im Windows-Programm', 'Antworten, Fragen-Log, Wörter, Ausprobieren'); await click({ sel: '.tabs button', text: 'Fragen-Log' }, { wait: 2200 }); await hide(); // ── 5. Android und Abspann ── chapter('android'); evt('whoosh'); await win.loadURL(card('

Android-App

  • Neues, helles Design mit schwebender Navigation
  • Wiedervorlage, Beobachten, „Dazu passend“
  • Digitalen Assistenten komplett verwalten
  • Widget in drei Größen, hell und dunkel
')); await sleep(3700); chapter('extras'); evt('whoosh'); await win.loadURL(card('

Und noch mehr

  • Chrome-Erweiterung: Zähler, Suche, Ticket anlegen
  • Minecraft-Plugin für Paper, Spigot und BungeeCord
  • Windows-Programm auch für Linux und Mac
  • Plugins und Sprachpakete, zum Beispiel Englisch
  • Updates der Apps direkt aus DeskHub
')); await sleep(3700); chapter('outro'); evt('whoosh'); await win.loadURL(card('
Kundenportal · Weboberfläche · Windows · Android
Mehr in der README
')); await sleep(4200); } catch (e) { console.error('Fehler:', e); process.exitCode = 1; } const total = (Date.now() - t0) / 1000; await stopRecording(); fs.writeFileSync(OUT + '.events.json', JSON.stringify({ duration: +total.toFixed(3), events }, null, 1)); console.log(`Aufnahme fertig: ${OUT} (${(written / FPS).toFixed(1)} Sekunden, ${size.width}x${size.height}), ${events.length} Ereignisse`); app.quit(); });