Upload via GUI (335 Dateien)
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* Öffentliche Webhook-Adresse für Plugins: POST /api/public/v1/plugin_webhook.php?p=<plugin>&e=<name>
|
||||||
|
* Das Plugin muss die Berechtigung "api.public" anfordern und prüft die Echtheit jeder Anfrage selbst (siehe plugin_register_webhook()).
|
||||||
|
* Hier gibt es nur: Methode POST, höchstens 256 KB, Aufruf des Handlers, Antwort als JSON oder Text.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../../../includes/bootstrap.php';
|
||||||
|
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
function webhook_fail(int $status, string $message): never
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['error' => $message], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||||
|
header('Allow: POST');
|
||||||
|
webhook_fail(405, 'Nur POST.');
|
||||||
|
}
|
||||||
|
$slug = (string) ($_GET['p'] ?? '');
|
||||||
|
$endpoint = (string) ($_GET['e'] ?? '');
|
||||||
|
$handler = plugin_state()['webhooks'][$slug][$endpoint] ?? null;
|
||||||
|
if (!is_callable($handler)) webhook_fail(404, 'Unbekannte Adresse.');
|
||||||
|
|
||||||
|
$body = (string) file_get_contents('php://input', false, null, 0, 262145);
|
||||||
|
if (strlen($body) > 262144) webhook_fail(413, 'Anfrage zu groß.');
|
||||||
|
$headers = [];
|
||||||
|
foreach ($_SERVER as $k => $v) {
|
||||||
|
if (str_starts_with($k, 'HTTP_')) $headers[strtolower(str_replace('_', '-', substr($k, 5)))] = (string) $v;
|
||||||
|
}
|
||||||
|
if (isset($_SERVER['CONTENT_TYPE'])) $headers['content-type'] = (string) $_SERVER['CONTENT_TYPE'];
|
||||||
|
|
||||||
|
$result = plugin_guard($slug, fn() => $handler(['method' => 'POST', 'headers' => $headers, 'body' => $body, 'query' => $_GET, 'ip' => client_ip()]), null);
|
||||||
|
if (!is_array($result)) webhook_fail(500, 'Fehler im Plugin.');
|
||||||
|
|
||||||
|
http_response_code((int) ($result['status'] ?? 200));
|
||||||
|
foreach ((array) ($result['headers'] ?? []) as $name => $value) {
|
||||||
|
if (preg_match('/^[A-Za-z0-9-]+$/', (string) $name) && !preg_match('/[\r\n]/', (string) $value)) header($name . ': ' . $value);
|
||||||
|
}
|
||||||
|
if (isset($result['json'])) {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode($result['json'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} else {
|
||||||
|
echo (string) ($result['body'] ?? '');
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* csat.php – Bewertungsseite für die Zufriedenheitsabfrage (Link aus der Mail).
|
||||||
|
* Ein Klick auf einen Stern in der Mail öffnet diese Seite mit vorgewählter Bewertung; gespeichert wird erst mit dem Knopf
|
||||||
|
* (so zählen automatische Link-Prüfungen von Mailprogrammen nicht als Bewertung).
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/includes/bootstrap.php';
|
||||||
|
|
||||||
|
$pdo = get_db();
|
||||||
|
$token = (string) ($_GET['t'] ?? $_POST['t'] ?? '');
|
||||||
|
$row = csat_find_by_token($pdo, $token);
|
||||||
|
$rating = (int) ($_POST['rating'] ?? $_GET['r'] ?? 0);
|
||||||
|
$error = null;
|
||||||
|
$done = false;
|
||||||
|
|
||||||
|
if ($row && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!rate_limit_check('csat', client_ip(), 30, 15)) {
|
||||||
|
$error = 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.';
|
||||||
|
} else {
|
||||||
|
$res = csat_record($pdo, $token, $rating, (string) ($_POST['comment'] ?? ''));
|
||||||
|
if ($res['ok']) $done = true; else $error = $res['error'];
|
||||||
|
$row = csat_find_by_token($pdo, $token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$already = $row && $row['answered_at'] !== null && !$done;
|
||||||
|
|
||||||
|
$pageTitle = 'Ihre Bewertung';
|
||||||
|
require __DIR__ . '/includes/header_customer.php';
|
||||||
|
?>
|
||||||
|
<div style="max-width:560px;margin:24px auto">
|
||||||
|
<?php if (!$row): ?>
|
||||||
|
<h1>Link nicht gültig</h1>
|
||||||
|
<p class="muted">Dieser Bewertungslink ist ungültig oder abgelaufen.</p>
|
||||||
|
<?php elseif ($done): ?>
|
||||||
|
<h1>Vielen Dank!</h1>
|
||||||
|
<p style="font-size:2rem;color:#f5b301;margin:8px 0"><?= e(csat_stars((int) $row['rating'])) ?></p>
|
||||||
|
<p>Ihre Bewertung für Ticket <strong>#<?= e($row['ticket_number']) ?></strong> ist bei uns angekommen.</p>
|
||||||
|
<?php elseif ($already): ?>
|
||||||
|
<h1>Bereits bewertet</h1>
|
||||||
|
<p class="muted">Sie haben Ticket #<?= e($row['ticket_number']) ?> schon bewertet. Vielen Dank!</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<h1>Wie zufrieden waren Sie?</h1>
|
||||||
|
<p class="muted">Ticket <strong>#<?= e($row['ticket_number']) ?></strong> – <?= e($row['subject']) ?></p>
|
||||||
|
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
|
||||||
|
<form method="post" class="card" style="padding:20px;margin-top:14px">
|
||||||
|
<input type="hidden" name="t" value="<?= e($token) ?>">
|
||||||
|
<div id="csat-stars" style="display:flex;gap:6px;flex-direction:row-reverse;justify-content:center;font-size:2.6rem;line-height:1">
|
||||||
|
<?php for ($i = 5; $i >= 1; $i--): ?>
|
||||||
|
<input type="radio" name="rating" id="r<?= $i ?>" value="<?= $i ?>" style="position:absolute;opacity:0;width:0;height:0" <?= $rating === $i ? 'checked' : '' ?> required>
|
||||||
|
<label for="r<?= $i ?>" title="<?= $i ?> von 5" class="csat-star" style="cursor:pointer;margin:0">★</label>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.csat-star { color:#cbd5e1; transition:color .1s }
|
||||||
|
#csat-stars input:checked ~ .csat-star, #csat-stars .csat-star:hover, #csat-stars .csat-star:hover ~ .csat-star { color:#f5b301 }
|
||||||
|
#csat-stars input:focus-visible + .csat-star { outline:2px solid var(--primary,#0f6f6a); border-radius:4px }
|
||||||
|
</style>
|
||||||
|
<p class="muted" style="text-align:center;font-size:.8rem;margin:4px 0 14px">1 Stern = unzufrieden · 5 Sterne = sehr zufrieden</p>
|
||||||
|
<label for="comment">Möchten Sie uns noch etwas mitteilen? (optional)</label>
|
||||||
|
<textarea id="comment" name="comment" rows="4" maxlength="2000" placeholder="Was hat gut geklappt, was können wir besser machen?"></textarea>
|
||||||
|
<div class="btn-row"><button type="submit" class="btn">Bewertung absenden</button></div>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* lang.php – Sprache wechseln (Klick auf eine Flagge): /lang.php?l=en&back=/pfad
|
||||||
|
* Speichert die Wahl in einem Cookie und leitet auf die Seite zurück, von der man kam.
|
||||||
|
*/
|
||||||
|
define('DESKHUB_NO_I18N', true);
|
||||||
|
require_once __DIR__ . '/includes/bootstrap.php';
|
||||||
|
|
||||||
|
$code = strtolower((string) ($_GET['l'] ?? ''));
|
||||||
|
if (isset(i18n_languages()[$code])) {
|
||||||
|
setcookie(I18N_COOKIE, $code, [
|
||||||
|
'expires' => time() + 365 * 86400,
|
||||||
|
'path' => '/',
|
||||||
|
'secure' => request_is_https(),
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
redirect(i18n_safe_back((string) ($_GET['back'] ?? '/')));
|
||||||
@@ -5,8 +5,9 @@ if (current_user()) { redirect('/home.php'); }
|
|||||||
|
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !csrf_valid()) {
|
||||||
verify_csrf();
|
$errors[] = 'Die Anmeldeseite war zu lange geöffnet und die Sitzung ist abgelaufen. Bitte melden Sie sich jetzt noch einmal an.';
|
||||||
|
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$email = trim($_POST['email'] ?? '');
|
$email = trim($_POST['email'] ?? '');
|
||||||
$pass = $_POST['password'] ?? '';
|
$pass = $_POST['password'] ?? '';
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ $imprintHtml = get_setting('footer_imprint_html', '');
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</head>
|
</head>
|
||||||
<body class="login-page" style="margin:0; display:flex; flex-direction:column; min-height:100vh">
|
<body class="login-page" style="margin:0; display:flex; flex-direction:column; min-height:100vh">
|
||||||
|
<?= i18n_switcher_html('floating') ?>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
@@ -148,5 +150,10 @@ $imprintHtml = get_setting('footer_imprint_html', '');
|
|||||||
Copyright © <?= date('Y') ?> <?= e($displayName) ?> – Alle Rechte vorbehalten.
|
Copyright © <?= date('Y') ?> <?= e($displayName) ?> – Alle Rechte vorbehalten.
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Hält die Sitzung der offenen Anmeldeseite am Leben: Der Server räumt ungenutzte Sitzungen nach etwa 24 Minuten auf, und dann wäre das
|
||||||
|
// Anmeldeformular ungültig. Ein kurzer Abruf alle 8 Minuten genügt.
|
||||||
|
setInterval(function () { fetch('/staff/session_ping.php?check=1', { credentials: 'same-origin', cache: 'no-store' }).catch(function () {}); }, 8 * 60 * 1000);
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* status.php – öffentliche Statusseite: Zustand der Dienste und aktuelle Meldungen.
|
||||||
|
* Wird im Mitarbeiterbereich unter „Statusseite“ gepflegt und nur gezeigt, wenn sie dort eingeschaltet ist.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/includes/bootstrap.php';
|
||||||
|
|
||||||
|
$pdo = get_db();
|
||||||
|
$activePage = 'status';
|
||||||
|
$pageTitle = (string) get_setting('status_page_title', 'Systemstatus');
|
||||||
|
if (!status_page_enabled()) {
|
||||||
|
http_response_code(404);
|
||||||
|
require __DIR__ . '/includes/header_customer.php';
|
||||||
|
echo '<h1>Seite nicht gefunden</h1><p class="muted">Die angeforderte Seite existiert nicht.</p><p><a href="/">Zurück zur Startseite</a></p>';
|
||||||
|
require __DIR__ . '/includes/footer_customer.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$states = status_component_states();
|
||||||
|
$overall = status_overall($pdo);
|
||||||
|
$components = status_components($pdo);
|
||||||
|
$incidents = status_incidents($pdo, 14);
|
||||||
|
$open = array_values(array_filter($incidents, fn($i) => $i['status'] !== 'resolved'));
|
||||||
|
$past = array_values(array_filter($incidents, fn($i) => $i['status'] === 'resolved'));
|
||||||
|
$incStates = status_incident_states();
|
||||||
|
|
||||||
|
// Aktualisiert sich selbst, damit eine offene Seite nicht veraltet
|
||||||
|
header('Cache-Control: no-cache');
|
||||||
|
header('Refresh: 120');
|
||||||
|
require __DIR__ . '/includes/header_customer.php';
|
||||||
|
?>
|
||||||
|
<div style="max-width:820px;margin:0 auto">
|
||||||
|
<h1><?= e($pageTitle) ?></h1>
|
||||||
|
<div class="card" style="padding:18px 22px;margin:16px 0;border-left:6px solid <?= e($overall['color']) ?>">
|
||||||
|
<strong style="font-size:1.2rem;color:<?= e($overall['color']) ?>"><?= e($overall['label']) ?></strong>
|
||||||
|
<div class="muted" style="font-size:.82rem;margin-top:2px">Stand: <?= e(date('d.m.Y H:i')) ?> Uhr</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php foreach ($open as $i): ?>
|
||||||
|
<div class="card" style="padding:16px 20px;margin-bottom:14px;border-left:6px solid #dc2626">
|
||||||
|
<strong><?= e($i['title']) ?></strong>
|
||||||
|
<span style="background:#fef3c7;color:#92400e;border-radius:20px;padding:2px 10px;font-size:.72rem;font-weight:700;margin-left:6px"><?= e($incStates[$i['status']] ?? $i['status']) ?></span>
|
||||||
|
<?php if ($i['component_name']): ?><div class="muted" style="font-size:.82rem">Betrifft: <?= e($i['component_name']) ?></div><?php endif; ?>
|
||||||
|
<?php foreach ($i['updates'] as $u): ?>
|
||||||
|
<div style="margin-top:10px;font-size:.9rem"><strong><?= e($incStates[$u['status']] ?? $u['status']) ?></strong> <span class="muted" style="font-size:.78rem">· <?= e(format_dt((string) $u['created_at'])) ?></span>
|
||||||
|
<?php if (trim((string) $u['body']) !== ''): ?><div><?= nl2br(e((string) $u['body'])) ?></div><?php endif; ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<div class="card" style="padding:6px 20px;margin:16px 0">
|
||||||
|
<?php if (!$components): ?><p class="muted" style="padding:12px 0">Es sind noch keine Dienste eingetragen.</p><?php endif; ?>
|
||||||
|
<?php foreach ($components as $k => $c): $st = $states[$c['status']] ?? $states['operational']; ?>
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;padding:12px 0;<?= $k ? 'border-top:1px solid var(--border,#e3e9ee)' : '' ?>">
|
||||||
|
<div><strong><?= e($c['name']) ?></strong><?php if (!empty($c['description'])): ?><div class="muted" style="font-size:.82rem"><?= e($c['description']) ?></div><?php endif; ?></div>
|
||||||
|
<span style="color:<?= e($st[1]) ?>;font-weight:700;white-space:nowrap"><span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:<?= e($st[1]) ?>;margin-right:6px"></span><?= e($st[0]) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="margin-top:28px">Vergangene Meldungen (letzte 14 Tage)</h2>
|
||||||
|
<?php if (!$past): ?><p class="muted">Keine Störungen in den letzten 14 Tagen.</p><?php endif; ?>
|
||||||
|
<?php foreach ($past as $i): ?>
|
||||||
|
<div style="padding:12px 0;border-top:1px solid var(--border,#e3e9ee)">
|
||||||
|
<strong><?= e($i['title']) ?></strong> <span class="muted" style="font-size:.8rem">· <?= e(format_dt((string) $i['created_at'])) ?> · behoben</span>
|
||||||
|
<?php foreach ($i['updates'] as $u): if (trim((string) $u['body']) === '') continue; ?>
|
||||||
|
<div style="font-size:.88rem;margin-top:4px"><?= nl2br(e((string) $u['body'])) ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
|
||||||
@@ -252,6 +252,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ticket-Vorlagen: ?template=ID füllt das Formular vor (nur Vorlagen, die für Kunden sichtbar sind)
|
||||||
|
$ticketTemplates = [];
|
||||||
|
$activeTemplateId = 0;
|
||||||
|
try {
|
||||||
|
$ticketTemplates = ticket_templates_list(get_db(), true);
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST' && (int) ($_GET['template'] ?? 0) > 0) {
|
||||||
|
$tpl = ticket_template_get(get_db(), (int) $_GET['template'], true);
|
||||||
|
if ($tpl) {
|
||||||
|
$activeTemplateId = (int) $tpl['id'];
|
||||||
|
$_POST['subject'] = (string) $tpl['subject'];
|
||||||
|
$_POST['message'] = (string) ($tpl['body'] ?? '');
|
||||||
|
if ($tpl['department_id']) $_POST['department_id'] = (string) $tpl['department_id'];
|
||||||
|
if ($tpl['help_topic_id']) $_POST['help_topic_id'] = (string) $tpl['help_topic_id'];
|
||||||
|
if ($tpl['priority_id']) $_POST['priority_id'] = (string) $tpl['priority_id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$ticketTemplates = []; // Datenbank noch nicht aktualisiert: Formular funktioniert wie bisher
|
||||||
|
}
|
||||||
$pageTitle = 'Neues Ticket';
|
$pageTitle = 'Neues Ticket';
|
||||||
$activePage = 'new';
|
$activePage = 'new';
|
||||||
require __DIR__ . '/includes/header_customer.php';
|
require __DIR__ . '/includes/header_customer.php';
|
||||||
@@ -266,6 +285,17 @@ require __DIR__ . '/includes/header_customer.php';
|
|||||||
<a href="/dashboard.php" class="btn secondary" style="white-space:nowrap">Meine Tickets</a>
|
<a href="/dashboard.php" class="btn secondary" style="white-space:nowrap">Meine Tickets</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if ($ticketTemplates): ?>
|
||||||
|
<div class="ticket-templates" style="margin:0 0 18px">
|
||||||
|
<div class="muted" style="font-size:.82rem;margin-bottom:6px">Womit können wir helfen? Eine Vorlage füllt das Formular für Sie vor:</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<?php foreach ($ticketTemplates as $__tp): ?>
|
||||||
|
<a href="/ticket_new.php?template=<?= (int) $__tp['id'] ?>" class="btn <?= $activeTemplateId === (int) $__tp['id'] ? '' : 'secondary' ?>" style="font-size:.85rem" <?= $__tp['description'] ? 'title="' . e((string) $__tp['description']) . '"' : '' ?>><?= e($__tp['name']) ?></a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if ($activeTemplateId): ?><a href="/ticket_new.php" class="muted" style="align-self:center;font-size:.82rem">Ohne Vorlage</a><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<?php foreach ($errors as $err): ?>
|
<?php foreach ($errors as $err): ?>
|
||||||
<div class="flash error"><?= e($err) ?></div>
|
<div class="flash error"><?= e($err) ?></div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# tools/demo-video/compose.sh: setzt die Aufnahme (raw.mp4) in ein modernes 1080p-Video: Farbverlauf-Hintergrund, abgerundetes Fenster mit Schatten,
|
||||||
|
# selbst erzeugte Musik (make_music.js) mit Ein- und Ausblenden. Außerdem eine kurze GIF-Vorschau.
|
||||||
|
# bash tools/demo-video/compose.sh <raw.mp4> <Zielordner>
|
||||||
|
set -euo pipefail
|
||||||
|
RAW="${1:?raw.mp4 fehlt}"
|
||||||
|
OUT="${2:?Zielordner fehlt}"
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
WORK="$OUT/.build"; rm -rf "$WORK"; mkdir -p "$WORK"
|
||||||
|
DUR="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$RAW")"
|
||||||
|
|
||||||
|
# Maske mit runden Ecken (Fenstergröße 1600x1000, Radius 26)
|
||||||
|
ffmpeg -loglevel error -y -f lavfi -i "color=c=black:s=1600x1000" -frames:v 1 \
|
||||||
|
-vf "format=gray,geq=lum='if(lte(hypot(max(max(26-X,X-(W-1-26)),0),max(max(26-Y,Y-(H-1-26)),0)),26),255,0)'" "$WORK/mask.png"
|
||||||
|
|
||||||
|
# Musik
|
||||||
|
node "$HERE/make_music.js" "$WORK/music.wav" "$RAW.events.json"
|
||||||
|
|
||||||
|
ffmpeg -loglevel error -y -i "$RAW" -loop 1 -framerate 30 -i "$WORK/mask.png" -i "$WORK/music.wav" \
|
||||||
|
-f lavfi -i "gradients=s=1920x1080:c0=0A5F95:c1=0E8C7C:x0=0:y0=0:x1=1920:y1=1080:d=$DUR:r=30" \
|
||||||
|
-filter_complex "[0:v]scale=1600:1000:flags=lanczos,format=yuva420p[rec];\
|
||||||
|
[1:v]format=gray,split[m1][m2];\
|
||||||
|
[rec][m1]alphamerge[win];\
|
||||||
|
[m2]pad=1760:1160:80:80:black,boxblur=38:3[mb];color=c=black:s=1760x1160,format=yuva420p[blka];[mb]format=gray[mbg];[blka][mbg]alphamerge,colorchannelmixer=aa=0.55[sh];\
|
||||||
|
[3:v]format=yuv420p[bg];\
|
||||||
|
[bg][sh]overlay=80:-18[t1];[t1][win]overlay=160:40,format=yuv420p[v]" \
|
||||||
|
-map "[v]" -map 2:a \
|
||||||
|
-c:v libx264 -crf 19 -preset medium -r 30 -c:a aac -b:a 160k -shortest -movflags +faststart "$OUT/deskhub-demo.mp4"
|
||||||
|
|
||||||
|
# GIF-Vorschau: der Teil mit dem digitalen Assistenten (kleiner, für die README)
|
||||||
|
ffmpeg -loglevel error -y -ss 4 -t 16 -i "$OUT/deskhub-demo.mp4" -an -vf "fps=8,scale=760:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=4" "$OUT/deskhub-demo.gif"
|
||||||
|
rm -rf "$WORK"
|
||||||
|
ls -la "$OUT"/deskhub-demo.*
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* tools/demo-video/make_music.js: erzeugt Musik und Geräusche für das Demo-Video selbst (reine Synthese, keine Aufnahmen, keine Samples,
|
||||||
|
* keine fremden Melodien). Das Stück ist damit ein eigenes Werk ohne Rechte Dritter und nicht bei GEMA oder einer anderen Verwertungsgesellschaft angemeldet.
|
||||||
|
* node tools/demo-video/make_music.js <ziel.wav> <raw.mp4.events.json>
|
||||||
|
* Die Musik folgt den Kapiteln der Aufnahme (Titel, Portal, Mitarbeiterbereich, Assistent, Windows, Android, Abspann): erst weiche Flächen,
|
||||||
|
* dann Zupf-Arpeggio und Bass, im Mitarbeiterbereich kommt ein leichter Beat dazu, im Assistenten-Kapitel wird es voll, beim Windows-Programm
|
||||||
|
* wieder ruhiger, vor dem Abspann baut ein Riser auf, und zum Schluss klingt ein großer Akkord aus. Dazu Tipp-, Klick-, Übergangs- und
|
||||||
|
* Hervorhebungsgeräusche genau zu den Zeitpunkten, die record.js mitgeschrieben hat.
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const OUT = process.argv[2] || 'music.wav';
|
||||||
|
const info = process.argv[3] && fs.existsSync(process.argv[3]) ? JSON.parse(fs.readFileSync(process.argv[3], 'utf8')) : { duration: parseFloat(process.argv[3] || '100'), events: [] };
|
||||||
|
const SECONDS = Math.ceil(info.duration) + 1;
|
||||||
|
const SR = 44100;
|
||||||
|
const BPM = 100;
|
||||||
|
const BEAT = 60 / BPM;
|
||||||
|
const BAR = 4 * BEAT;
|
||||||
|
const N = Math.floor(SECONDS * SR);
|
||||||
|
const M_L = new Float32Array(N), M_R = new Float32Array(N); // Musik
|
||||||
|
const S_L = new Float32Array(N), S_R = new Float32Array(N); // Geräusche
|
||||||
|
|
||||||
|
const hz = (midi) => 440 * Math.pow(2, (midi - 69) / 12);
|
||||||
|
const CHORDS = [[57, 60, 64, 67], [53, 57, 60, 64], [48, 52, 55, 59], [55, 59, 62, 64]]; // Am7, Fmaj7, Cmaj7, G6
|
||||||
|
const BASS = [45, 41, 36, 43];
|
||||||
|
let seed = 987654321; const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296 * 2 - 1; };
|
||||||
|
|
||||||
|
// ── Kapitel mit den Zeitpunkten der Aufnahme, Anfänge auf den nächsten Schlag gerastet ──
|
||||||
|
const chapters = info.events.filter((e) => e.type === 'chapter').map((e) => ({ name: e.name, t: Math.ceil(e.t / BEAT) * BEAT, raw: e.t }));
|
||||||
|
if (!chapters.length) chapters.push({ name: 'portal', t: 0, raw: 0 });
|
||||||
|
const PLAN = {
|
||||||
|
intro: { bell: 1 }, portal: { arp: 1, bass: 1 }, staff: { arp: 1, bass: 1, kick: 1, hat: 1 },
|
||||||
|
assistant: { arp: 1, arp2: 1, bass: 1, kick: 2, hat: 1, shaker: 1 }, windows: { arp: 1, bass: 1, soft: 1 },
|
||||||
|
android: { arp: 1, bass: 1, riser: 1 }, outro: { bell: 1, final: 1 },
|
||||||
|
};
|
||||||
|
const planAt = (t) => { let c = chapters[0]; for (const ch of chapters) if (ch.t <= t + 1e-6) c = ch; return { name: c.name, ...(PLAN[c.name] || {}) }; };
|
||||||
|
const chapterStart = (name) => (chapters.find((c) => c.name === name) || {}).raw;
|
||||||
|
|
||||||
|
// ── Klangerzeuger ──
|
||||||
|
function tone(buf, start, dur, f, gain, pan, kind, attack, release) {
|
||||||
|
const [bl, br] = buf;
|
||||||
|
const n0 = Math.floor(start * SR), n1 = Math.min(N, Math.floor((start + dur + release) * SR));
|
||||||
|
const gl = gain * (1 - pan), gr = gain * (1 + pan);
|
||||||
|
for (let i = Math.max(0, n0); i < n1; i++) {
|
||||||
|
const t = (i - n0) / SR;
|
||||||
|
let env, s;
|
||||||
|
if (kind === 'pluck') { env = Math.exp(-t * 5.2) * Math.min(1, t / 0.004); s = 0.7 * Math.sin(2 * Math.PI * f * t) + 0.2 * Math.sin(4 * Math.PI * f * t) + 0.1 * Math.sin(6 * Math.PI * f * t); }
|
||||||
|
else if (kind === 'bell') { env = Math.exp(-t * 1.7) * Math.min(1, t / 0.003); s = Math.sin(2 * Math.PI * f * t) + 0.5 * Math.sin(2 * Math.PI * f * 2.76 * t) * Math.exp(-t * 3) + 0.25 * Math.sin(2 * Math.PI * f * 5.4 * t) * Math.exp(-t * 6); }
|
||||||
|
else { env = t < attack ? t / attack : 1; if (t > dur) env *= Math.max(0, 1 - (t - dur) / release); s = Math.sin(2 * Math.PI * f * t) + 0.35 * Math.sin(2 * Math.PI * f * 1.004 * t) + 0.15 * Math.sin(4 * Math.PI * f * t); }
|
||||||
|
const v = s * env; bl[i] += v * gl; br[i] += v * gr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mus = [M_L, M_R], sfx = [S_L, S_R];
|
||||||
|
function kick(start, gain) {
|
||||||
|
const n0 = Math.floor(start * SR), n1 = Math.min(N, n0 + Math.floor(0.35 * SR)); let ph = 0;
|
||||||
|
for (let i = n0; i < n1; i++) { const t = (i - n0) / SR; ph += 2 * Math.PI * (48 + 90 * Math.exp(-t * 28)) / SR; const v = Math.sin(ph) * Math.exp(-t * 9) * gain; M_L[i] += v; M_R[i] += v; }
|
||||||
|
}
|
||||||
|
function hat(start, gain, len = 0.07) {
|
||||||
|
const n0 = Math.floor(start * SR), n1 = Math.min(N, n0 + Math.floor(len * SR)); let prev = 0;
|
||||||
|
for (let i = n0; i < n1; i++) { const t = (i - n0) / SR; const x = rnd(); const hp = x - prev * 0.97; prev = x; const v = hp * Math.exp(-t * (len < 0.05 ? 90 : 60)) * gain; M_L[i] += v * 0.9; M_R[i] += v * 1.1; }
|
||||||
|
}
|
||||||
|
/** Rauschen mit gleitender Filterfrequenz: Übergangs-Wusch (auf = true) oder Riser. */
|
||||||
|
function sweep(buf, start, dur, gain, from, to, shape) {
|
||||||
|
const [bl, br] = buf; const n0 = Math.floor(start * SR), n1 = Math.min(N, Math.floor((start + dur) * SR)); let lp = 0, lp2 = 0;
|
||||||
|
for (let i = Math.max(0, n0); i < n1; i++) {
|
||||||
|
const k = (i - n0) / (n1 - n0), fc = from * Math.pow(to / from, k), a = Math.min(0.99, 2 * Math.PI * fc / SR);
|
||||||
|
const x = rnd(); lp += a * (x - lp); lp2 += a * (lp - lp2);
|
||||||
|
const bp = lp - lp2; // einfacher Bandpass
|
||||||
|
const env = shape === 'swell' ? Math.pow(k, 1.6) : Math.sin(Math.PI * Math.min(1, k * 1.15)) ** 1.5;
|
||||||
|
const v = bp * env * gain * 7; bl[i] += v * (1 - 0.3 * (k - .5)); br[i] += v * (1 + 0.3 * (k - .5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function key(t) { const g = 0.05 + 0.02 * rnd(); const f = 1800 + 500 * rnd(); const n0 = Math.floor(t * SR), n1 = Math.min(N, n0 + Math.floor(0.03 * SR)); let prev = 0;
|
||||||
|
for (let i = n0; i < n1; i++) { const u = (i - n0) / SR; const x = rnd(); const hp = x - prev * 0.9; prev = x; const v = (hp * 0.6 + Math.sin(2 * Math.PI * f * u) * 0.5) * Math.exp(-u * 230) * g; S_L[i] += v; S_R[i] += v; } }
|
||||||
|
function click(t) { const n0 = Math.floor(t * SR), n1 = Math.min(N, n0 + Math.floor(0.09 * SR)); let ph = 0, prev = 0;
|
||||||
|
for (let i = n0; i < n1; i++) { const u = (i - n0) / SR; ph += 2 * Math.PI * (880 - 520 * Math.min(1, u * 14)) / SR; const x = rnd(); const hp = x - prev * 0.8; prev = x; const v = (Math.sin(ph) * 0.8 + hp * 0.3) * Math.exp(-u * 60) * 0.13; S_L[i] += v; S_R[i] += v; } }
|
||||||
|
function pop(t) { const n0 = Math.floor(t * SR), n1 = Math.min(N, n0 + Math.floor(0.2 * SR)); let ph = 0;
|
||||||
|
for (let i = n0; i < n1; i++) { const u = (i - n0) / SR; ph += 2 * Math.PI * (520 + 520 * Math.min(1, u * 9)) / SR; const v = Math.sin(ph) * Math.exp(-u * 14) * 0.09; S_L[i] += v; S_R[i] += v; } }
|
||||||
|
function ding(t) { tone(sfx, t, 0.05, 1568, 0.07, -0.1, 'bell', 0, 0.05); tone(sfx, t + 0.0, 0.05, 2349, 0.035, 0.12, 'bell', 0, 0.05); }
|
||||||
|
function boom(t) { const n0 = Math.floor(t * SR), n1 = Math.min(N, n0 + Math.floor(1.8 * SR)); let ph = 0;
|
||||||
|
for (let i = n0; i < n1; i++) { const u = (i - n0) / SR; ph += 2 * Math.PI * (38 + 70 * Math.exp(-u * 9)) / SR; const v = Math.sin(ph) * Math.exp(-u * 2.2) * 0.55; M_L[i] += v; M_R[i] += v; } }
|
||||||
|
|
||||||
|
// ── Musik nach Takten ──
|
||||||
|
const bars = Math.ceil(SECONDS / BAR) + 1;
|
||||||
|
const finalStart = (chapters.find((c) => c.name === 'outro') || { t: SECONDS - 6 }).t;
|
||||||
|
for (let bar = 0; bar < bars; bar++) {
|
||||||
|
const t0 = bar * BAR, ci = Math.floor(bar / 2) % 4, chord = CHORDS[ci];
|
||||||
|
const p = planAt(t0), isOutro = t0 >= finalStart - 1e-6;
|
||||||
|
// Flächen: ständig, im Abspann und beim Windows-Teil etwas anders gewichtet
|
||||||
|
if (bar % 2 === 0) chord.forEach((m, k) => tone(mus, t0, 2 * BAR - 0.1, hz(m), isOutro ? 0.10 : 0.075, (k - 1.5) * 0.25, 'pad', 1.4, 2.0));
|
||||||
|
if (p.bass && !isOutro) { tone(mus, t0, BEAT * 1.5, hz(BASS[ci]), 0.2, 0, 'pad', 0.02, 0.4); tone(mus, t0 + BEAT * 2.5, BEAT * 1.2, hz(BASS[ci]), 0.16, 0, 'pad', 0.02, 0.4); }
|
||||||
|
for (let s = 0; s < 8; s++) {
|
||||||
|
const tt = t0 + s * BEAT / 2, q = planAt(tt);
|
||||||
|
if (q.arp && s % 1 === 0) {
|
||||||
|
const order = [0, 2, 3, 2, 1, 3, 2, 1];
|
||||||
|
tone(mus, tt, 0.5, hz(chord[order[s]] + 12), (q.soft ? 0.08 : 0.11) + (s % 2 ? 0 : 0.03), (s % 4 - 1.5) * 0.18, 'pluck', 0, 0);
|
||||||
|
if (q.arp2 && s % 2) tone(mus, tt, 0.4, hz(chord[order[(s + 3) % 8]] + 24), 0.05, (s % 4 - 1.5) * -0.2, 'pluck', 0, 0);
|
||||||
|
}
|
||||||
|
if (q.bell && s % 4 === 0 && !(isOutro && s % 2)) tone(mus, tt, 1.2, hz(chord[(s / 4 + bar) % 4 | 0] + 12), isOutro ? 0.09 : 0.06, (s - 3) * 0.1, 'bell', 0, 0);
|
||||||
|
if (q.kick && s % 4 === 0) kick(tt, 0.46);
|
||||||
|
else if (q.kick === 2 && s % 2 === 0) kick(tt, 0.3);
|
||||||
|
if (q.hat && s % 2) hat(tt, 0.06);
|
||||||
|
if (q.shaker) hat(tt + BEAT / 4, 0.025, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Übergänge in der Musik: Riser vor dem Abspann, großer Schlag und heller Schlussakkord
|
||||||
|
const outroT = chapterStart('outro');
|
||||||
|
if (outroT !== undefined) {
|
||||||
|
sweep(mus, Math.max(0, outroT - 2.6), 2.6, 0.2, 300, 5200, 'swell');
|
||||||
|
boom(outroT);
|
||||||
|
[60, 64, 67, 72, 76].forEach((m, k) => tone(mus, outroT, 6, hz(m), 0.06, (k - 2) * 0.2, 'pad', 0.4, 2.5));
|
||||||
|
}
|
||||||
|
// einfaches Echo für Raumgefühl (nur Musik)
|
||||||
|
const D1 = Math.floor(0.375 * SR), D2 = Math.floor(0.5 * SR);
|
||||||
|
for (let i = D1; i < N; i++) { M_L[i] += M_R[i - D1] * 0.28; M_R[i] += M_L[i - D1] * 0.28; }
|
||||||
|
for (let i = D2; i < N; i++) { M_L[i] += M_L[i - D2] * 0.14; M_R[i] += M_R[i - D2] * 0.14; }
|
||||||
|
|
||||||
|
// ── Geräusche nach den Ereignissen der Aufnahme ──
|
||||||
|
let lastKey = -1;
|
||||||
|
for (const e of info.events) {
|
||||||
|
if (e.type === 'key') { if (e.t - lastKey > 0.025) key(e.t); lastKey = e.t; }
|
||||||
|
else if (e.type === 'click') click(e.t);
|
||||||
|
else if (e.type === 'whoosh') sweep(sfx, Math.max(0, e.t - 0.1), 0.85, 0.075, 350, 3800, 'whoosh');
|
||||||
|
else if (e.type === 'pop') pop(e.t);
|
||||||
|
else if (e.type === 'ding') ding(e.t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mischen: Musik leiser als Geräusche, normalisieren, sanft ein- und ausblenden ──
|
||||||
|
const MUSIC_GAIN = 0.85, SFX_GAIN = 1.0;
|
||||||
|
let peak = 0; for (let i = 0; i < N; i++) { const a = M_L[i] * MUSIC_GAIN + S_L[i] * SFX_GAIN, b = M_R[i] * MUSIC_GAIN + S_R[i] * SFX_GAIN; peak = Math.max(peak, Math.abs(a), Math.abs(b)); }
|
||||||
|
const g = 0.72 / (peak || 1);
|
||||||
|
const buf = Buffer.alloc(44 + N * 4);
|
||||||
|
buf.write('RIFF', 0); buf.writeUInt32LE(36 + N * 4, 4); buf.write('WAVEfmt ', 8); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(2, 22);
|
||||||
|
buf.writeUInt32LE(SR, 24); buf.writeUInt32LE(SR * 4, 28); buf.writeUInt16LE(4, 32); buf.writeUInt16LE(16, 34); buf.write('data', 36); buf.writeUInt32LE(N * 4, 40);
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const t = i / SR, fade = Math.min(1, t / 1.2) * Math.min(1, (SECONDS - t) / 3.2);
|
||||||
|
const a = (M_L[i] * MUSIC_GAIN + S_L[i] * SFX_GAIN) * g * fade, b = (M_R[i] * MUSIC_GAIN + S_R[i] * SFX_GAIN) * g * fade;
|
||||||
|
buf.writeInt16LE(Math.max(-32767, Math.min(32767, Math.round(a * 32767))), 44 + i * 4);
|
||||||
|
buf.writeInt16LE(Math.max(-32767, Math.min(32767, Math.round(b * 32767))), 46 + i * 4);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(OUT, buf);
|
||||||
|
console.log(`Musik und Geräusche: ${OUT} (${SECONDS} s, ${info.events.length} Ereignisse, Kapitel: ${chapters.map((c) => c.name + '@' + c.raw.toFixed(1)).join(', ')})`);
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
'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 <raw.mp4>.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 <Ordner> 2. php -S 127.0.0.1:8097 -t <Ordner> &
|
||||||
|
* 3. electron tools/demo-video/record.js <raw.mp4> 4. bash tools/demo-video/compose.sh <raw.mp4> <Zielordner>
|
||||||
|
*/
|
||||||
|
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 = '<svg width="30" height="30" viewBox="0 0 24 24"><path d="M4 2l15 9.5-6.6 1.4L9.5 20z" fill="#fff" stroke="#10212B" stroke-width="1.4" stroke-linejoin="round"/></svg>';
|
||||||
|
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 = '<div class="n">' + n + '</div><div>' + text + (sub ? '<small>' + sub + '</small>' : '') + '</div>'; 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(`<!doctype html><meta charset="utf-8"><style>
|
||||||
|
html,body{margin:0;width:${W}px;height:${H}px;overflow:hidden;font-family:'Segoe UI',Arial,sans-serif;color:#fff;background:linear-gradient(135deg,#0A5F95,#0E8C7C)}
|
||||||
|
.c{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center;padding:0 110px;box-sizing:border-box}
|
||||||
|
.logo{font-size:110px;font-weight:800;letter-spacing:-3px;animation:pop .9s cubic-bezier(.2,.9,.2,1) both}.logo span{color:#FFD79A}
|
||||||
|
.sub{font-size:38px;opacity:0;animation:up .8s .5s ease forwards}.small{font-size:24px;opacity:0;animation:up .8s 1s ease forwards;margin-top:34px}
|
||||||
|
.row{display:flex;gap:26px;margin-top:46px}.chip{background:rgba(255,255,255,.16);border-radius:40px;padding:12px 26px;font-size:26px;opacity:0;animation:up .7s ease forwards}
|
||||||
|
.chip:nth-child(1){animation-delay:1.0s}.chip:nth-child(2){animation-delay:1.25s}.chip:nth-child(3){animation-delay:1.5s}.chip:nth-child(4){animation-delay:1.75s}
|
||||||
|
h2{font-size:68px;margin:0 0 20px;animation:up .8s ease both}ul{list-style:none;padding:0;margin:20px 0 0;text-align:left;font-size:35px;line-height:1.75}
|
||||||
|
li{opacity:0;animation:up .7s ease forwards}li:nth-child(1){animation-delay:.35s}li:nth-child(2){animation-delay:.7s}li:nth-child(3){animation-delay:1.05s}li:nth-child(4){animation-delay:1.4s}li:nth-child(5){animation-delay:1.75s}
|
||||||
|
li:before{content:"✔";margin-right:20px;color:#FFD79A}
|
||||||
|
@keyframes pop{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:none}}@keyframes up{from{opacity:0;transform:translateY(26px)}to{opacity:1;transform:none}}${css}</style><div class="c">${html}</div>`);
|
||||||
|
|
||||||
|
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('<div class="logo">Desk<span>Hub</span></div>'));
|
||||||
|
await sleep(500);
|
||||||
|
startRecording();
|
||||||
|
try {
|
||||||
|
// ── Titel ──
|
||||||
|
chapter('intro');
|
||||||
|
await win.loadURL(card('<div class="logo">Desk<span>Hub</span></div><div class="sub">Das Helpdesk-System zum Selbsthosten</div><div class="row"><div class="chip">Kundenportal</div><div class="chip">Digitaler Assistent</div><div class="chip">Windows</div><div class="chip">Android</div></div>'));
|
||||||
|
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();
|
||||||
|
|
||||||
|
// ── 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('<h2>Android-App</h2><ul><li>Neues, helles Design mit schwebender Navigation</li><li>Wiedervorlage, Beobachten, „Dazu passend“</li><li>Digitalen Assistenten komplett verwalten</li><li>Widget in drei Größen, hell und dunkel</li></ul>'));
|
||||||
|
await sleep(3700);
|
||||||
|
chapter('outro');
|
||||||
|
evt('whoosh');
|
||||||
|
await win.loadURL(card('<div class="logo" style="font-size:84px">Desk<span>Hub</span></div><div class="sub">Kundenportal · Weboberfläche · Windows · Android</div><div class="small">Mehr in der README</div>'));
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
if (defined('DESKHUB_VERSION')) return;
|
if (defined('DESKHUB_VERSION')) return;
|
||||||
|
|
||||||
define('DESKHUB_VERSION', '1.0.29');
|
define('DESKHUB_VERSION', '1.0.35');
|
||||||
define('DESKHUB_RELEASE_URL', 'https://git.viper.ipv64.net/M_Viper/deskhub/releases');
|
define('DESKHUB_RELEASE_URL', 'https://git.viper.ipv64.net/M_Viper/deskhub/releases');
|
||||||
|
|
||||||
// Kein eingebautes Zugangstoken: Das Update liest die öffentlichen Releases. Ist das Repository privat, kann ein Token
|
// Kein eingebautes Zugangstoken: Das Update liest die öffentlichen Releases. Ist das Repository privat, kann ein Token
|
||||||
|
|||||||
Reference in New Issue
Block a user