diff --git a/CHANGELOG.md b/CHANGELOG.md
index ada093b..6b2a994 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,36 @@
All notable changes to this project will be documented in this file.
+## 2.5.1 - 2026-08-03
+
+### Added
+
+- Template regions now include a dedicated advanced animation modal for editing intro, outro, and Attention Seekers settings directly.
+
+### Changed
+
+- Template region animation settings now store as a single JSON object with `intro`, `outro`, and `loop` keys.
+- The template editor advanced animation field now edits the JSON object directly instead of separate preset selects.
+- Animate.css is now loaded from vendored assets so template previews and player rendering use the local copy.
+
+### Fixed
+
+- Existing databases now migrate legacy animation columns into `animation_json` and then drop the old columns.
+
+## 2.5.0 - 2026-08-03
+
+### Added
+
+- Template regions now support intro, exit, and Attention Seekers animation presets in the editor, with in-canvas preview controls.
+
+### Changed
+
+- The template editor now locks editing while preview playback is running so animation changes are easier to inspect.
+
+### Fixed
+
+- Existing databases now receive the template region animation columns through the versioned migration path.
+
## 2.4.2 - 2026-08-02
### Added
diff --git a/Dockerfile b/Dockerfile
index 3ae367a..1bf6bfb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,6 +9,9 @@ RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
+RUN mkdir -p /app/src/web/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/web/public/vendor/animate.css/animate.min.css
+RUN mkdir -p /app/src/player/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/player/public/vendor/animate.css/animate.min.css
+
RUN mkdir -p /app/media
EXPOSE 3000
diff --git a/package-lock.json b/package-lock.json
index dc59762..221cc66 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,14 +1,15 @@
{
"name": "pulse-signage",
- "version": "2.1.0",
+ "version": "2.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
- "version": "2.0.0",
+ "version": "2.5.1",
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
+ "animate.css": "^4.1.1",
"bootstrap-icons": "1.11.3",
"cropperjs": "^1.6.2",
"dotenv": "^17.4.2",
@@ -755,6 +756,12 @@
"node": ">= 14"
}
},
+ "node_modules/animate.css": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz",
+ "integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==",
+ "license": "MIT"
+ },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
diff --git a/package.json b/package.json
index e71b894..ef8d50b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
- "version": "2.4.2",
+ "version": "2.5.1",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
@@ -13,10 +13,12 @@
"start:web": "node -r dotenv/config src/web.js",
"start:player": "node -r dotenv/config src/player.js",
"dev:web": "nodemon -r dotenv/config src/web.js",
- "dev:player": "nodemon -r dotenv/config src/player.js"
+ "dev:player": "nodemon -r dotenv/config src/player.js",
+ "test": "node --test"
},
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
+ "animate.css": "^4.1.1",
"bootstrap-icons": "1.11.3",
"cropperjs": "^1.6.2",
"dotenv": "^17.4.2",
diff --git a/src/data/templates.js b/src/data/templates.js
index 4184be5..17d5d08 100644
--- a/src/data/templates.js
+++ b/src/data/templates.js
@@ -1,6 +1,7 @@
// Template data access helpers and region normalization logic.
const { parseJsonSafe, readFormArray } = require('./utils');
+const animationPresets = require('../web/public/js/templates/animation-presets');
function sanitizeBackgroundColor(value) {
const raw = String(value || '').trim();
@@ -23,6 +24,61 @@ function normalizeTemplateRegionLockRatio(value) {
return rawRatio.replace(/\s+/g, '');
}
+function normalizeTemplateRegionAnimation(value) {
+ if (animationPresets && typeof animationPresets.normalize === 'function') {
+ return animationPresets.normalize(value);
+ }
+
+ const allowed = new Set(
+ Array.isArray(animationPresets && animationPresets.allValues) && animationPresets.allValues.length
+ ? animationPresets.allValues
+ : ['none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown', 'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp', 'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown', 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp', 'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp', 'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp', 'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight', 'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight', 'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce']
+ );
+ const raw = String(value || '').trim().toLowerCase();
+ return allowed.has(raw) ? raw : 'none';
+}
+
+function normalizeTemplateRegionAnimationStep(value, fallbackPreset) {
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ const normalized = {};
+ Object.keys(value).forEach((key) => {
+ normalized[key] = value[key];
+ });
+ normalized.preset = normalizeTemplateRegionAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
+ return normalized;
+ }
+
+ return {
+ preset: normalizeTemplateRegionAnimation(typeof value === 'string' ? value : fallbackPreset || 'none')
+ };
+}
+
+function normalizeTemplateRegionAnimationConfig(value) {
+ let raw = value;
+ if (typeof raw === 'string') {
+ const text = String(raw || '').trim();
+ if (!text) {
+ raw = null;
+ } else {
+ try {
+ raw = JSON.parse(text);
+ } catch (_error) {
+ raw = null;
+ }
+ }
+ }
+
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ raw = {};
+ }
+
+ return {
+ intro: normalizeTemplateRegionAnimationStep(raw.intro, 'none'),
+ outro: normalizeTemplateRegionAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
+ loop: normalizeTemplateRegionAnimationStep(raw.loop, 'none')
+ };
+}
+
function normalizeTemplateRegionName(value) {
return String(value || '').trim();
}
@@ -57,7 +113,7 @@ async function fetchTemplateById(pool, id) {
return null;
}
const template = templates[0];
- const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
+ const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
template.regions = regions;
return template;
}
@@ -70,7 +126,7 @@ async function fetchTemplatesData(pool) {
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC
`);
- const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
+ const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
return { templates, templateRegions };
}
@@ -86,6 +142,7 @@ function extractTemplateRegions(body) {
region_type: regionType,
label: String(region.region_name || region.label || region.region_key || '').trim(),
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
+ animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
x: Number(region.x || 0),
y: Number(region.y || 0),
width: Number(region.width || 100),
@@ -101,6 +158,7 @@ function extractTemplateRegions(body) {
const labels = readFormArray(body, 'region_label[]');
const types = readFormArray(body, 'region_type[]');
const ratios = readFormArray(body, 'region_lock_ratio[]');
+ const animationJsons = readFormArray(body, 'region_animation_json[]');
const xs = readFormArray(body, 'region_x[]');
const ys = readFormArray(body, 'region_y[]');
const widths = readFormArray(body, 'region_width[]');
@@ -120,6 +178,7 @@ function extractTemplateRegions(body) {
region_type: regionType,
label: name,
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
+ animation_json: normalizeTemplateRegionAnimationConfig(animationJsons[i]),
x: Number(xs[i] || 0),
y: Number(ys[i] || 0),
width: Number(widths[i] || 100),
diff --git a/src/db/index.js b/src/db/index.js
index 95d49f8..44d84a9 100644
--- a/src/db/index.js
+++ b/src/db/index.js
@@ -52,6 +52,7 @@ async function ensureSchema(pool, options) {
label VARCHAR(255) NOT NULL,
font_family VARCHAR(100) NULL,
lock_ratio VARCHAR(20) NULL,
+ animation_json JSON NULL,
x INT NOT NULL DEFAULT 0,
y INT NOT NULL DEFAULT 0,
width INT NOT NULL DEFAULT 100,
diff --git a/src/db/migrations.js b/src/db/migrations.js
index fa2ad6c..8f21bac 100644
--- a/src/db/migrations.js
+++ b/src/db/migrations.js
@@ -107,9 +107,7 @@ const VERSIONED_MIGRATIONS = [
INDEX idx_announcements_modified_at (modified_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
-
}
-
},
{
version: '2.4.0',
@@ -217,6 +215,13 @@ const VERSIONED_MIGRATIONS = [
`);
}
},
+ {
+ version: '2.5.1',
+ label: 'v2.5.1 template animation json schema',
+ run: async function (pool) {
+ await ensureColumn(pool, 'c_template_regions', 'animation_json', 'JSON NULL', 'lock_ratio');
+ }
+ }
];
async function columnExists(pool, tableName, columnName) {
@@ -260,7 +265,13 @@ async function ensureForeignKey(pool, tableName, constraintName, columnName, ref
async function dropColumnIfExists(pool, tableName, columnName) {
if (await columnExists(pool, tableName, columnName)) {
- await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName);
+ try {
+ await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName);
+ } catch (error) {
+ if (!error || (error.code !== 'ER_CANT_DROP_FIELD_OR_KEY' && error.errno !== 1091)) {
+ throw error;
+ }
+ }
}
}
diff --git a/src/player/player-page-announcements.js b/src/player/player-page-announcements.js
index 6c14c5d..e2d5b01 100644
--- a/src/player/player-page-announcements.js
+++ b/src/player/player-page-announcements.js
@@ -1,6 +1,10 @@
// Announcement overlay polling and rendering helpers.
(function () {
+ if (window.__pulseThumbnailPreview) {
+ return;
+ }
+
var announcementLayer = null;
var announcementRefreshTimer = null;
var announcementPollTimer = null;
diff --git a/src/player/player-page.template.html b/src/player/player-page.template.html
index 92621c3..e65d528 100644
--- a/src/player/player-page.template.html
+++ b/src/player/player-page.template.html
@@ -6,6 +6,7 @@
{{TITLE}}
+
{{{STYLESHEETS}}}
diff --git a/src/player/playlist.js b/src/player/playlist.js
index 7bb92b3..b61d053 100644
--- a/src/player/playlist.js
+++ b/src/player/playlist.js
@@ -121,7 +121,7 @@ function createPlayerPlaylistService(options) {
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id IN (?)
`, [templateIds]);
- [regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
+ [regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
templateRows.forEach(function (template) {
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
templatesById[template.id] = template;
diff --git a/src/player/public/css/player.css b/src/player/public/css/player.css
index 66fd1b2..bef4abb 100644
--- a/src/player/public/css/player.css
+++ b/src/player/public/css/player.css
@@ -22,6 +22,21 @@ body.onboarding-page #app {
display: none;
}
+body.thumbnail-preview *,
+body.thumbnail-preview *::before,
+body.thumbnail-preview *::after {
+ animation: none !important;
+ animation-delay: 0s !important;
+ animation-duration: 0s !important;
+ animation-iteration-count: 1 !important;
+ transition: none !important;
+}
+
+body.thumbnail-preview .player-announcement-layer,
+body.thumbnail-preview .player-offline-banner {
+ display: none !important;
+}
+
#app {
width: 100%;
height: 100%;
diff --git a/src/player/public/js/player-page-commands.js b/src/player/public/js/player-page-commands.js
index 715c2c6..d6cdbff 100644
--- a/src/player/public/js/player-page-commands.js
+++ b/src/player/public/js/player-page-commands.js
@@ -6,6 +6,8 @@ function getCurrentViewport() {
};
}
+var slideOutroTimers = [];
+
// Command websocket and player-state helpers.
// Send the current playback state to the command websocket.
function sendCommandState(currentSlide) {
@@ -65,6 +67,56 @@ function clearSlideTimer() {
window.clearTimeout(timer);
timer = null;
}
+ clearSlideOutroTimer();
+}
+
+// Cancel any pending outro triggers for the current slide.
+function clearSlideOutroTimer() {
+ if (!Array.isArray(slideOutroTimers) || !slideOutroTimers.length) {
+ slideOutroTimers = [];
+ return;
+ }
+
+ slideOutroTimers.forEach(function (timerId) {
+ window.clearTimeout(timerId);
+ });
+ slideOutroTimers = [];
+}
+
+// Return the rendered slide root that is currently on screen.
+function getCurrentSlideRoot() {
+ var shells = Array.prototype.slice.call(app ? app.querySelectorAll('.slide-shell') : []);
+ if (shells.length) {
+ return shells[shells.length - 1];
+ }
+ return app && app.firstElementChild ? app.firstElementChild : app;
+}
+
+// Schedule the outgoing slide animation for each region so it finishes before removal.
+function scheduleSlideOutro(holdDelayMs) {
+ clearSlideOutroTimer();
+
+ var currentRoot = getCurrentSlideRoot();
+ if (!currentRoot || typeof getRegionAnimationPhaseTimings !== 'function' || typeof playRegionAnimation !== 'function') {
+ return;
+ }
+
+ var regionTimings = getRegionAnimationPhaseTimings(currentRoot, 'outro');
+ if (!regionTimings.length) {
+ return;
+ }
+
+ var slideDurationMs = Math.max(1, Math.round(Number(holdDelayMs || 0)));
+ slideOutroTimers = regionTimings.map(function (entry) {
+ var timingMs = Math.max(0, Math.round(Number(entry && entry.timingMs || 0)));
+ var triggerDelayMs = Math.max(0, slideDurationMs - timingMs);
+ return window.setTimeout(function () {
+ if (!entry || !entry.element || !entry.element.isConnected) {
+ return;
+ }
+ playRegionAnimation(entry.element, 'outro');
+ }, triggerDelayMs);
+ });
}
// Schedule the next slide transition.
@@ -72,10 +124,12 @@ function scheduleSlideAdvance(delayMs) {
clearSlideTimer();
var holdDelayMs = Math.max(1, Number(delayMs || 0));
slideExpiresAt = Date.now() + holdDelayMs;
+ scheduleSlideOutro(holdDelayMs);
timer = window.setTimeout(function () {
timer = null;
slideExpiresAt = null;
pausedRemainingMs = null;
+ clearSlideOutroTimer();
const activeSlides = getCurrentActiveSlides();
if (activeSlides.length < 2) {
refresh();
@@ -89,13 +143,10 @@ function scheduleSlideAdvance(delayMs) {
}, holdDelayMs);
}
-// Account for fade only when it is enabled so the transition is centered on the slide boundary.
+// Return the slide duration without shifting it for fade timing.
function getSlideHoldDelay(delayMs) {
var holdDelayMs = Math.max(1, Number(delayMs || 0));
- if (!currentPlaylistFadeBetweenSlides) {
- return holdDelayMs;
- }
- return Math.max(1, holdDelayMs - (slideFadeDurationMs / 2));
+ return holdDelayMs;
}
// Cancel any pending fade-transition cleanup.
@@ -114,11 +165,19 @@ function getPlayerRegionModules() {
return window.pulsePlayerRegionTypes.list();
}
+function isThumbnailPreview() {
+ return Boolean(window.__pulseThumbnailPreview);
+}
+
function runRegionLifecycle(root, lifecycleName) {
if (!root) {
return;
}
+ if (isThumbnailPreview() && lifecycleName === 'initRegion') {
+ return;
+ }
+
getPlayerRegionModules().forEach(function (entry) {
var module = entry && entry.definition ? entry.definition : null;
if (!module || typeof module[lifecycleName] !== 'function') {
@@ -208,8 +267,15 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(app);
}
- initializeRegionInstances(app);
+ if (!isThumbnailPreview()) {
+ initializeRegionInstances(app);
+ }
initializeRenderedVideoPlayback(app);
+ if (typeof playRegionAnimations === 'function') {
+ window.requestAnimationFrame(function () {
+ playRegionAnimations(app, 'intro');
+ });
+ }
return app.firstElementChild;
}
@@ -277,14 +343,18 @@ function renderSlideMarkup(markup, shouldFade) {
window.requestAnimationFrame(function () {
nextShell.style.opacity = '1';
previousShell.style.opacity = '0';
+ if (typeof playRegionAnimations === 'function') {
+ playRegionAnimations(nextShell, 'intro');
+ }
});
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell);
}
- initializeRegionInstances(nextShell);
-
+ if (!isThumbnailPreview()) {
+ initializeRegionInstances(nextShell);
+ }
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
slideTransitionTimer = window.setTimeout(function () {
diff --git a/src/player/public/js/player-page-rendering.js b/src/player/public/js/player-page-rendering.js
index 42c6c9a..673b1d3 100644
--- a/src/player/public/js/player-page-rendering.js
+++ b/src/player/public/js/player-page-rendering.js
@@ -53,6 +53,276 @@ function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
};
}
+function normalizePlayerAnimationStep(value, fallbackPreset) {
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ return {
+ preset: String(value.preset || fallbackPreset || 'none').trim(),
+ duration_ms: Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null,
+ delay_ms: Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null,
+ iterations: Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null
+ };
+ }
+
+ return {
+ preset: String(typeof value === 'string' ? value : fallbackPreset || 'none').trim(),
+ duration_ms: null,
+ delay_ms: null,
+ iterations: null
+ };
+}
+
+function normalizePlayerAnimationConfig(value) {
+ var raw = value;
+ if (typeof raw === 'string') {
+ var text = String(raw || '').trim();
+ if (!text) {
+ raw = null;
+ } else {
+ try {
+ raw = JSON.parse(text);
+ } catch (_error) {
+ raw = null;
+ }
+ }
+ }
+
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ raw = {};
+ }
+
+ return {
+ intro: normalizePlayerAnimationStep(raw.intro, 'none'),
+ outro: normalizePlayerAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
+ loop: normalizePlayerAnimationStep(raw.loop, 'none')
+ };
+}
+
+function hasPlayerAnimation(config) {
+ return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) {
+ return String((config[stepName] && config[stepName].preset) || '').trim() && String((config[stepName] && config[stepName].preset) || '').trim() !== 'none';
+ }));
+}
+
+function decorateRegionMarkup(markup, region) {
+ if (!markup || !region || !hasPlayerAnimation(region.animationConfig)) {
+ return markup;
+ }
+
+ var animationJson = escapeHtml(JSON.stringify(region.animationConfig));
+ return markup.replace(/^(\s*)<([a-z0-9-]+)(\s[^>]*)?>/i, function (match, leadingWhitespace, tagName, attributes) {
+ return leadingWhitespace + '<' + tagName + (attributes || '') + ' data-animation-json="' + animationJson + '">';
+ });
+}
+
+function clearRegionAnimationClasses(root) {
+ if (!root) {
+ return;
+ }
+
+ var elements = [];
+ if (typeof root.matches === 'function' && root.matches('[data-animation-json]')) {
+ elements.push(root);
+ }
+ Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
+ elements.push(element);
+ });
+
+ elements.forEach(function (element) {
+ if (!element) {
+ return;
+ }
+ element.classList.remove('animate__animated', 'animate__infinite');
+ element.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
+ Array.prototype.slice.call(element.classList || []).forEach(function (className) {
+ if (String(className || '').indexOf('animate__') === 0) {
+ element.classList.remove(className);
+ }
+ });
+ element.style.removeProperty('--animate-duration');
+ element.style.removeProperty('--animate-delay');
+ element.style.removeProperty('--animate-repeat');
+ });
+}
+
+function isAttentionSeekerAnimation(preset) {
+ return ['bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat'].indexOf(String(preset || '').trim()) !== -1;
+}
+
+function applyAnimationStep(element, step, phase) {
+ if (!element || !step) {
+ return;
+ }
+
+ var preset = String(step.preset || 'none').trim();
+ if (!preset || preset === 'none') {
+ return;
+ }
+
+ element.classList.add('animate__animated', 'animate__' + preset);
+ element.style.setProperty('--animate-duration', String(Math.max(1, Number(step.duration_ms || 0) || 1000)) + 'ms');
+ if (Number(step.delay_ms || 0) > 0) {
+ element.style.setProperty('--animate-delay', String(Math.max(0, Number(step.delay_ms || 0))) + 'ms');
+ } else {
+ element.style.removeProperty('--animate-delay');
+ }
+
+ if (phase === 'loop') {
+ var repeatCount = Number(step.iterations);
+ if (!Number.isFinite(repeatCount) || repeatCount < 1) {
+ repeatCount = 1;
+ }
+ if (repeatCount > 1) {
+ element.classList.add('animate__repeat-1');
+ }
+ element.style.setProperty('--animate-repeat', String(repeatCount));
+ return;
+ }
+
+ if (isAttentionSeekerAnimation(preset) && Number(step.iterations || 0) > 1) {
+ element.style.setProperty('--animate-repeat', String(Math.max(1, Number(step.iterations || 1))));
+ }
+}
+
+function getAnimationStepTimingMs(step) {
+ if (!step) {
+ return 0;
+ }
+
+ var preset = String(step.preset || 'none').trim();
+ if (!preset || preset === 'none') {
+ return 0;
+ }
+
+ var durationMs = Number(step.duration_ms);
+ if (!Number.isFinite(durationMs) || durationMs <= 0) {
+ durationMs = 1000;
+ }
+
+ var delayMs = Number(step.delay_ms);
+ if (!Number.isFinite(delayMs) || delayMs < 0) {
+ delayMs = 0;
+ }
+
+ var iterations = Number(step.iterations);
+ if (!Number.isFinite(iterations) || iterations < 1) {
+ iterations = 1;
+ }
+
+ return delayMs + (durationMs * iterations);
+}
+
+function getRegionAnimationPhaseTimingMs(root, phase) {
+ var timings = getRegionAnimationPhaseTimings(root, phase);
+ return timings.reduce(function (maxTimingMs, entry) {
+ return Math.max(maxTimingMs, Number(entry && entry.timingMs || 0));
+ }, 0);
+}
+
+function getRegionAnimationPhaseTimings(root, phase) {
+ if (!root || isThumbnailPreview()) {
+ return [];
+ }
+
+ var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
+ if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
+ normalizedPhase = 'intro';
+ }
+
+ var timings = [];
+ Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
+ if (!element) {
+ return;
+ }
+
+ var config;
+ try {
+ config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
+ } catch (_error) {
+ config = null;
+ }
+
+ if (!config) {
+ return;
+ }
+
+ var timingMs = getAnimationStepTimingMs(normalizedPhase === 'outro' ? config.outro : config.intro);
+ if (timingMs > 0) {
+ timings.push({
+ element: element,
+ timingMs: timingMs
+ });
+ }
+ });
+
+ return timings;
+}
+
+function playRegionAnimation(element, phase) {
+ if (!element || isThumbnailPreview()) {
+ return;
+ }
+
+ var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
+ if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
+ normalizedPhase = 'intro';
+ }
+
+ var config;
+ try {
+ config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
+ } catch (_error) {
+ config = null;
+ }
+
+ if (!config) {
+ return;
+ }
+
+ element.dataset.animationPhase = normalizedPhase;
+ clearRegionAnimationClasses(element);
+
+ if (normalizedPhase === 'outro') {
+ applyAnimationStep(element, config.outro, 'outro');
+ return;
+ }
+
+ if (config.intro && String(config.intro.preset || '').trim() && String(config.intro.preset || '').trim() !== 'none') {
+ applyAnimationStep(element, config.intro, 'intro');
+ if (config.loop && String(config.loop.preset || '').trim() && String(config.loop.preset || '').trim() !== 'none') {
+ element.addEventListener('animationend', function handleAnimationEnd(event) {
+ if (event.target !== element) {
+ return;
+ }
+ if (String(element.dataset.animationPhase || '').trim() !== 'intro') {
+ return;
+ }
+ element.removeEventListener('animationend', handleAnimationEnd);
+ clearRegionAnimationClasses(element);
+ applyAnimationStep(element, config.loop, 'loop');
+ });
+ }
+ return;
+ }
+
+ applyAnimationStep(element, config.loop, 'loop');
+}
+
+function playRegionAnimations(root, phase) {
+ if (!root || isThumbnailPreview()) {
+ return;
+ }
+
+ var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
+ if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
+ normalizedPhase = 'intro';
+ }
+
+ var elements = Array.prototype.slice.call(root.querySelectorAll('[data-animation-json]'));
+ elements.forEach(function (element) {
+ playRegionAnimation(element, normalizedPhase);
+ });
+}
+
function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
if (!document || !document.documentElement) {
return;
@@ -474,6 +744,7 @@ function getTemplateLayout(template) {
regionKey: region.region_key,
regionType: region.region_type,
label: region.label,
+ animationJson: region.animation_json || null,
baseStyle: baseStyle,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight,
@@ -578,7 +849,10 @@ function renderTemplateSlideMarkup(slide) {
const layout = plan ? plan.layout : null;
const regions = layout ? layout.regions.map(function (region) {
const regionContent = content[region.regionKey] || {};
- return plan.renderRegion(region, regionContent);
+ const markup = plan.renderRegion(region, regionContent);
+ return decorateRegionMarkup(markup, {
+ animationConfig: normalizePlayerAnimationConfig(region.animationJson)
+ });
}).join('') : '';
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
if (layout) {
diff --git a/src/player/public/vendor/animate.css/animate.min.css b/src/player/public/vendor/animate.css/animate.min.css
new file mode 100644
index 0000000..76d2fe1
--- /dev/null
+++ b/src/player/public/vendor/animate.css/animate.min.css
@@ -0,0 +1,7 @@
+@charset "UTF-8";/*!
+ * animate.css - https://animate.style/
+ * Version - 4.1.1
+ * Licensed under the MIT license - http://opensource.org/licenses/MIT
+ *
+ * Copyright (c) 2020 Animate.css
+ */:root{--animate-duration:1s;--animate-delay:1s;--animate-repeat:1}.animate__animated{-webkit-animation-duration:1s;animation-duration:1s;-webkit-animation-duration:var(--animate-duration);animation-duration:var(--animate-duration);-webkit-animation-fill-mode:both;animation-fill-mode:both}.animate__animated.animate__infinite{-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite}.animate__animated.animate__repeat-1{-webkit-animation-iteration-count:1;animation-iteration-count:1;-webkit-animation-iteration-count:var(--animate-repeat);animation-iteration-count:var(--animate-repeat)}.animate__animated.animate__repeat-2{-webkit-animation-iteration-count:2;animation-iteration-count:2;-webkit-animation-iteration-count:calc(var(--animate-repeat)*2);animation-iteration-count:calc(var(--animate-repeat)*2)}.animate__animated.animate__repeat-3{-webkit-animation-iteration-count:3;animation-iteration-count:3;-webkit-animation-iteration-count:calc(var(--animate-repeat)*3);animation-iteration-count:calc(var(--animate-repeat)*3)}.animate__animated.animate__delay-1s{-webkit-animation-delay:1s;animation-delay:1s;-webkit-animation-delay:var(--animate-delay);animation-delay:var(--animate-delay)}.animate__animated.animate__delay-2s{-webkit-animation-delay:2s;animation-delay:2s;-webkit-animation-delay:calc(var(--animate-delay)*2);animation-delay:calc(var(--animate-delay)*2)}.animate__animated.animate__delay-3s{-webkit-animation-delay:3s;animation-delay:3s;-webkit-animation-delay:calc(var(--animate-delay)*3);animation-delay:calc(var(--animate-delay)*3)}.animate__animated.animate__delay-4s{-webkit-animation-delay:4s;animation-delay:4s;-webkit-animation-delay:calc(var(--animate-delay)*4);animation-delay:calc(var(--animate-delay)*4)}.animate__animated.animate__delay-5s{-webkit-animation-delay:5s;animation-delay:5s;-webkit-animation-delay:calc(var(--animate-delay)*5);animation-delay:calc(var(--animate-delay)*5)}.animate__animated.animate__faster{-webkit-animation-duration:.5s;animation-duration:.5s;-webkit-animation-duration:calc(var(--animate-duration)/2);animation-duration:calc(var(--animate-duration)/2)}.animate__animated.animate__fast{-webkit-animation-duration:.8s;animation-duration:.8s;-webkit-animation-duration:calc(var(--animate-duration)*0.8);animation-duration:calc(var(--animate-duration)*0.8)}.animate__animated.animate__slow{-webkit-animation-duration:2s;animation-duration:2s;-webkit-animation-duration:calc(var(--animate-duration)*2);animation-duration:calc(var(--animate-duration)*2)}.animate__animated.animate__slower{-webkit-animation-duration:3s;animation-duration:3s;-webkit-animation-duration:calc(var(--animate-duration)*3);animation-duration:calc(var(--animate-duration)*3)}@media (prefers-reduced-motion:reduce),print{.animate__animated{-webkit-animation-duration:1ms!important;animation-duration:1ms!important;-webkit-transition-duration:1ms!important;transition-duration:1ms!important;-webkit-animation-iteration-count:1!important;animation-iteration-count:1!important}.animate__animated[class*=Out]{opacity:0}}@-webkit-keyframes bounce{0%,20%,53%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0);transform:translateZ(0)}40%,43%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06);-webkit-transform:translate3d(0,-30px,0) scaleY(1.1);transform:translate3d(0,-30px,0) scaleY(1.1)}70%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06);-webkit-transform:translate3d(0,-15px,0) scaleY(1.05);transform:translate3d(0,-15px,0) scaleY(1.05)}80%{-webkit-transition-timing-function:cubic-bezier(.215,.61,.355,1);transition-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0) scaleY(.95);transform:translateZ(0) scaleY(.95)}90%{-webkit-transform:translate3d(0,-4px,0) scaleY(1.02);transform:translate3d(0,-4px,0) scaleY(1.02)}}@keyframes bounce{0%,20%,53%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0);transform:translateZ(0)}40%,43%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06);-webkit-transform:translate3d(0,-30px,0) scaleY(1.1);transform:translate3d(0,-30px,0) scaleY(1.1)}70%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06);-webkit-transform:translate3d(0,-15px,0) scaleY(1.05);transform:translate3d(0,-15px,0) scaleY(1.05)}80%{-webkit-transition-timing-function:cubic-bezier(.215,.61,.355,1);transition-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0) scaleY(.95);transform:translateZ(0) scaleY(.95)}90%{-webkit-transform:translate3d(0,-4px,0) scaleY(1.02);transform:translate3d(0,-4px,0) scaleY(1.02)}}.animate__bounce{-webkit-animation-name:bounce;animation-name:bounce;-webkit-transform-origin:center bottom;transform-origin:center bottom}@-webkit-keyframes flash{0%,50%,to{opacity:1}25%,75%{opacity:0}}@keyframes flash{0%,50%,to{opacity:1}25%,75%{opacity:0}}.animate__flash{-webkit-animation-name:flash;animation-name:flash}@-webkit-keyframes pulse{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}50%{-webkit-transform:scale3d(1.05,1.05,1.05);transform:scale3d(1.05,1.05,1.05)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes pulse{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}50%{-webkit-transform:scale3d(1.05,1.05,1.05);transform:scale3d(1.05,1.05,1.05)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.animate__pulse{-webkit-animation-name:pulse;animation-name:pulse;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}@-webkit-keyframes rubberBand{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}30%{-webkit-transform:scale3d(1.25,.75,1);transform:scale3d(1.25,.75,1)}40%{-webkit-transform:scale3d(.75,1.25,1);transform:scale3d(.75,1.25,1)}50%{-webkit-transform:scale3d(1.15,.85,1);transform:scale3d(1.15,.85,1)}65%{-webkit-transform:scale3d(.95,1.05,1);transform:scale3d(.95,1.05,1)}75%{-webkit-transform:scale3d(1.05,.95,1);transform:scale3d(1.05,.95,1)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes rubberBand{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}30%{-webkit-transform:scale3d(1.25,.75,1);transform:scale3d(1.25,.75,1)}40%{-webkit-transform:scale3d(.75,1.25,1);transform:scale3d(.75,1.25,1)}50%{-webkit-transform:scale3d(1.15,.85,1);transform:scale3d(1.15,.85,1)}65%{-webkit-transform:scale3d(.95,1.05,1);transform:scale3d(.95,1.05,1)}75%{-webkit-transform:scale3d(1.05,.95,1);transform:scale3d(1.05,.95,1)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.animate__rubberBand{-webkit-animation-name:rubberBand;animation-name:rubberBand}@-webkit-keyframes shakeX{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}20%,40%,60%,80%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}}@keyframes shakeX{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}20%,40%,60%,80%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}}.animate__shakeX{-webkit-animation-name:shakeX;animation-name:shakeX}@-webkit-keyframes shakeY{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}20%,40%,60%,80%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}}@keyframes shakeY{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}20%,40%,60%,80%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}}.animate__shakeY{-webkit-animation-name:shakeY;animation-name:shakeY}@-webkit-keyframes headShake{0%{-webkit-transform:translateX(0);transform:translateX(0)}6.5%{-webkit-transform:translateX(-6px) rotateY(-9deg);transform:translateX(-6px) rotateY(-9deg)}18.5%{-webkit-transform:translateX(5px) rotateY(7deg);transform:translateX(5px) rotateY(7deg)}31.5%{-webkit-transform:translateX(-3px) rotateY(-5deg);transform:translateX(-3px) rotateY(-5deg)}43.5%{-webkit-transform:translateX(2px) rotateY(3deg);transform:translateX(2px) rotateY(3deg)}50%{-webkit-transform:translateX(0);transform:translateX(0)}}@keyframes headShake{0%{-webkit-transform:translateX(0);transform:translateX(0)}6.5%{-webkit-transform:translateX(-6px) rotateY(-9deg);transform:translateX(-6px) rotateY(-9deg)}18.5%{-webkit-transform:translateX(5px) rotateY(7deg);transform:translateX(5px) rotateY(7deg)}31.5%{-webkit-transform:translateX(-3px) rotateY(-5deg);transform:translateX(-3px) rotateY(-5deg)}43.5%{-webkit-transform:translateX(2px) rotateY(3deg);transform:translateX(2px) rotateY(3deg)}50%{-webkit-transform:translateX(0);transform:translateX(0)}}.animate__headShake{-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;-webkit-animation-name:headShake;animation-name:headShake}@-webkit-keyframes swing{20%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}40%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}60%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}80%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes swing{20%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}40%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}60%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}80%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}.animate__swing{-webkit-transform-origin:top center;transform-origin:top center;-webkit-animation-name:swing;animation-name:swing}@-webkit-keyframes tada{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}10%,20%{-webkit-transform:scale3d(.9,.9,.9) rotate(-3deg);transform:scale3d(.9,.9,.9) rotate(-3deg)}30%,50%,70%,90%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(3deg);transform:scale3d(1.1,1.1,1.1) rotate(3deg)}40%,60%,80%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(-3deg);transform:scale3d(1.1,1.1,1.1) rotate(-3deg)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes tada{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}10%,20%{-webkit-transform:scale3d(.9,.9,.9) rotate(-3deg);transform:scale3d(.9,.9,.9) rotate(-3deg)}30%,50%,70%,90%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(3deg);transform:scale3d(1.1,1.1,1.1) rotate(3deg)}40%,60%,80%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(-3deg);transform:scale3d(1.1,1.1,1.1) rotate(-3deg)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.animate__tada{-webkit-animation-name:tada;animation-name:tada}@-webkit-keyframes wobble{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}15%{-webkit-transform:translate3d(-25%,0,0) rotate(-5deg);transform:translate3d(-25%,0,0) rotate(-5deg)}30%{-webkit-transform:translate3d(20%,0,0) rotate(3deg);transform:translate3d(20%,0,0) rotate(3deg)}45%{-webkit-transform:translate3d(-15%,0,0) rotate(-3deg);transform:translate3d(-15%,0,0) rotate(-3deg)}60%{-webkit-transform:translate3d(10%,0,0) rotate(2deg);transform:translate3d(10%,0,0) rotate(2deg)}75%{-webkit-transform:translate3d(-5%,0,0) rotate(-1deg);transform:translate3d(-5%,0,0) rotate(-1deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes wobble{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}15%{-webkit-transform:translate3d(-25%,0,0) rotate(-5deg);transform:translate3d(-25%,0,0) rotate(-5deg)}30%{-webkit-transform:translate3d(20%,0,0) rotate(3deg);transform:translate3d(20%,0,0) rotate(3deg)}45%{-webkit-transform:translate3d(-15%,0,0) rotate(-3deg);transform:translate3d(-15%,0,0) rotate(-3deg)}60%{-webkit-transform:translate3d(10%,0,0) rotate(2deg);transform:translate3d(10%,0,0) rotate(2deg)}75%{-webkit-transform:translate3d(-5%,0,0) rotate(-1deg);transform:translate3d(-5%,0,0) rotate(-1deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__wobble{-webkit-animation-name:wobble;animation-name:wobble}@-webkit-keyframes jello{0%,11.1%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}22.2%{-webkit-transform:skewX(-12.5deg) skewY(-12.5deg);transform:skewX(-12.5deg) skewY(-12.5deg)}33.3%{-webkit-transform:skewX(6.25deg) skewY(6.25deg);transform:skewX(6.25deg) skewY(6.25deg)}44.4%{-webkit-transform:skewX(-3.125deg) skewY(-3.125deg);transform:skewX(-3.125deg) skewY(-3.125deg)}55.5%{-webkit-transform:skewX(1.5625deg) skewY(1.5625deg);transform:skewX(1.5625deg) skewY(1.5625deg)}66.6%{-webkit-transform:skewX(-.78125deg) skewY(-.78125deg);transform:skewX(-.78125deg) skewY(-.78125deg)}77.7%{-webkit-transform:skewX(.390625deg) skewY(.390625deg);transform:skewX(.390625deg) skewY(.390625deg)}88.8%{-webkit-transform:skewX(-.1953125deg) skewY(-.1953125deg);transform:skewX(-.1953125deg) skewY(-.1953125deg)}}@keyframes jello{0%,11.1%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}22.2%{-webkit-transform:skewX(-12.5deg) skewY(-12.5deg);transform:skewX(-12.5deg) skewY(-12.5deg)}33.3%{-webkit-transform:skewX(6.25deg) skewY(6.25deg);transform:skewX(6.25deg) skewY(6.25deg)}44.4%{-webkit-transform:skewX(-3.125deg) skewY(-3.125deg);transform:skewX(-3.125deg) skewY(-3.125deg)}55.5%{-webkit-transform:skewX(1.5625deg) skewY(1.5625deg);transform:skewX(1.5625deg) skewY(1.5625deg)}66.6%{-webkit-transform:skewX(-.78125deg) skewY(-.78125deg);transform:skewX(-.78125deg) skewY(-.78125deg)}77.7%{-webkit-transform:skewX(.390625deg) skewY(.390625deg);transform:skewX(.390625deg) skewY(.390625deg)}88.8%{-webkit-transform:skewX(-.1953125deg) skewY(-.1953125deg);transform:skewX(-.1953125deg) skewY(-.1953125deg)}}.animate__jello{-webkit-animation-name:jello;animation-name:jello;-webkit-transform-origin:center;transform-origin:center}@-webkit-keyframes heartBeat{0%{-webkit-transform:scale(1);transform:scale(1)}14%{-webkit-transform:scale(1.3);transform:scale(1.3)}28%{-webkit-transform:scale(1);transform:scale(1)}42%{-webkit-transform:scale(1.3);transform:scale(1.3)}70%{-webkit-transform:scale(1);transform:scale(1)}}@keyframes heartBeat{0%{-webkit-transform:scale(1);transform:scale(1)}14%{-webkit-transform:scale(1.3);transform:scale(1.3)}28%{-webkit-transform:scale(1);transform:scale(1)}42%{-webkit-transform:scale(1.3);transform:scale(1.3)}70%{-webkit-transform:scale(1);transform:scale(1)}}.animate__heartBeat{-webkit-animation-name:heartBeat;animation-name:heartBeat;-webkit-animation-duration:1.3s;animation-duration:1.3s;-webkit-animation-duration:calc(var(--animate-duration)*1.3);animation-duration:calc(var(--animate-duration)*1.3);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}@-webkit-keyframes backInDown{0%{-webkit-transform:translateY(-1200px) scale(.7);transform:translateY(-1200px) scale(.7);opacity:.7}80%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}@keyframes backInDown{0%{-webkit-transform:translateY(-1200px) scale(.7);transform:translateY(-1200px) scale(.7);opacity:.7}80%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}.animate__backInDown{-webkit-animation-name:backInDown;animation-name:backInDown}@-webkit-keyframes backInLeft{0%{-webkit-transform:translateX(-2000px) scale(.7);transform:translateX(-2000px) scale(.7);opacity:.7}80%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}@keyframes backInLeft{0%{-webkit-transform:translateX(-2000px) scale(.7);transform:translateX(-2000px) scale(.7);opacity:.7}80%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}.animate__backInLeft{-webkit-animation-name:backInLeft;animation-name:backInLeft}@-webkit-keyframes backInRight{0%{-webkit-transform:translateX(2000px) scale(.7);transform:translateX(2000px) scale(.7);opacity:.7}80%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}@keyframes backInRight{0%{-webkit-transform:translateX(2000px) scale(.7);transform:translateX(2000px) scale(.7);opacity:.7}80%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}.animate__backInRight{-webkit-animation-name:backInRight;animation-name:backInRight}@-webkit-keyframes backInUp{0%{-webkit-transform:translateY(1200px) scale(.7);transform:translateY(1200px) scale(.7);opacity:.7}80%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}@keyframes backInUp{0%{-webkit-transform:translateY(1200px) scale(.7);transform:translateY(1200px) scale(.7);opacity:.7}80%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}.animate__backInUp{-webkit-animation-name:backInUp;animation-name:backInUp}@-webkit-keyframes backOutDown{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:translateY(700px) scale(.7);transform:translateY(700px) scale(.7);opacity:.7}}@keyframes backOutDown{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:translateY(700px) scale(.7);transform:translateY(700px) scale(.7);opacity:.7}}.animate__backOutDown{-webkit-animation-name:backOutDown;animation-name:backOutDown}@-webkit-keyframes backOutLeft{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:translateX(-2000px) scale(.7);transform:translateX(-2000px) scale(.7);opacity:.7}}@keyframes backOutLeft{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:translateX(-2000px) scale(.7);transform:translateX(-2000px) scale(.7);opacity:.7}}.animate__backOutLeft{-webkit-animation-name:backOutLeft;animation-name:backOutLeft}@-webkit-keyframes backOutRight{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:translateX(2000px) scale(.7);transform:translateX(2000px) scale(.7);opacity:.7}}@keyframes backOutRight{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:translateX(2000px) scale(.7);transform:translateX(2000px) scale(.7);opacity:.7}}.animate__backOutRight{-webkit-animation-name:backOutRight;animation-name:backOutRight}@-webkit-keyframes backOutUp{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:translateY(-700px) scale(.7);transform:translateY(-700px) scale(.7);opacity:.7}}@keyframes backOutUp{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:translateY(-700px) scale(.7);transform:translateY(-700px) scale(.7);opacity:.7}}.animate__backOutUp{-webkit-animation-name:backOutUp;animation-name:backOutUp}@-webkit-keyframes bounceIn{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}20%{-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}40%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}60%{opacity:1;-webkit-transform:scale3d(1.03,1.03,1.03);transform:scale3d(1.03,1.03,1.03)}80%{-webkit-transform:scale3d(.97,.97,.97);transform:scale3d(.97,.97,.97)}to{opacity:1;-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes bounceIn{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}20%{-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}40%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}60%{opacity:1;-webkit-transform:scale3d(1.03,1.03,1.03);transform:scale3d(1.03,1.03,1.03)}80%{-webkit-transform:scale3d(.97,.97,.97);transform:scale3d(.97,.97,.97)}to{opacity:1;-webkit-transform:scaleX(1);transform:scaleX(1)}}.animate__bounceIn{-webkit-animation-duration:.75s;animation-duration:.75s;-webkit-animation-duration:calc(var(--animate-duration)*0.75);animation-duration:calc(var(--animate-duration)*0.75);-webkit-animation-name:bounceIn;animation-name:bounceIn}@-webkit-keyframes bounceInDown{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,-3000px,0) scaleY(3);transform:translate3d(0,-3000px,0) scaleY(3)}60%{opacity:1;-webkit-transform:translate3d(0,25px,0) scaleY(.9);transform:translate3d(0,25px,0) scaleY(.9)}75%{-webkit-transform:translate3d(0,-10px,0) scaleY(.95);transform:translate3d(0,-10px,0) scaleY(.95)}90%{-webkit-transform:translate3d(0,5px,0) scaleY(.985);transform:translate3d(0,5px,0) scaleY(.985)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInDown{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,-3000px,0) scaleY(3);transform:translate3d(0,-3000px,0) scaleY(3)}60%{opacity:1;-webkit-transform:translate3d(0,25px,0) scaleY(.9);transform:translate3d(0,25px,0) scaleY(.9)}75%{-webkit-transform:translate3d(0,-10px,0) scaleY(.95);transform:translate3d(0,-10px,0) scaleY(.95)}90%{-webkit-transform:translate3d(0,5px,0) scaleY(.985);transform:translate3d(0,5px,0) scaleY(.985)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__bounceInDown{-webkit-animation-name:bounceInDown;animation-name:bounceInDown}@-webkit-keyframes bounceInLeft{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(-3000px,0,0) scaleX(3);transform:translate3d(-3000px,0,0) scaleX(3)}60%{opacity:1;-webkit-transform:translate3d(25px,0,0) scaleX(1);transform:translate3d(25px,0,0) scaleX(1)}75%{-webkit-transform:translate3d(-10px,0,0) scaleX(.98);transform:translate3d(-10px,0,0) scaleX(.98)}90%{-webkit-transform:translate3d(5px,0,0) scaleX(.995);transform:translate3d(5px,0,0) scaleX(.995)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInLeft{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(-3000px,0,0) scaleX(3);transform:translate3d(-3000px,0,0) scaleX(3)}60%{opacity:1;-webkit-transform:translate3d(25px,0,0) scaleX(1);transform:translate3d(25px,0,0) scaleX(1)}75%{-webkit-transform:translate3d(-10px,0,0) scaleX(.98);transform:translate3d(-10px,0,0) scaleX(.98)}90%{-webkit-transform:translate3d(5px,0,0) scaleX(.995);transform:translate3d(5px,0,0) scaleX(.995)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__bounceInLeft{-webkit-animation-name:bounceInLeft;animation-name:bounceInLeft}@-webkit-keyframes bounceInRight{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(3000px,0,0) scaleX(3);transform:translate3d(3000px,0,0) scaleX(3)}60%{opacity:1;-webkit-transform:translate3d(-25px,0,0) scaleX(1);transform:translate3d(-25px,0,0) scaleX(1)}75%{-webkit-transform:translate3d(10px,0,0) scaleX(.98);transform:translate3d(10px,0,0) scaleX(.98)}90%{-webkit-transform:translate3d(-5px,0,0) scaleX(.995);transform:translate3d(-5px,0,0) scaleX(.995)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInRight{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(3000px,0,0) scaleX(3);transform:translate3d(3000px,0,0) scaleX(3)}60%{opacity:1;-webkit-transform:translate3d(-25px,0,0) scaleX(1);transform:translate3d(-25px,0,0) scaleX(1)}75%{-webkit-transform:translate3d(10px,0,0) scaleX(.98);transform:translate3d(10px,0,0) scaleX(.98)}90%{-webkit-transform:translate3d(-5px,0,0) scaleX(.995);transform:translate3d(-5px,0,0) scaleX(.995)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__bounceInRight{-webkit-animation-name:bounceInRight;animation-name:bounceInRight}@-webkit-keyframes bounceInUp{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,3000px,0) scaleY(5);transform:translate3d(0,3000px,0) scaleY(5)}60%{opacity:1;-webkit-transform:translate3d(0,-20px,0) scaleY(.9);transform:translate3d(0,-20px,0) scaleY(.9)}75%{-webkit-transform:translate3d(0,10px,0) scaleY(.95);transform:translate3d(0,10px,0) scaleY(.95)}90%{-webkit-transform:translate3d(0,-5px,0) scaleY(.985);transform:translate3d(0,-5px,0) scaleY(.985)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInUp{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,3000px,0) scaleY(5);transform:translate3d(0,3000px,0) scaleY(5)}60%{opacity:1;-webkit-transform:translate3d(0,-20px,0) scaleY(.9);transform:translate3d(0,-20px,0) scaleY(.9)}75%{-webkit-transform:translate3d(0,10px,0) scaleY(.95);transform:translate3d(0,10px,0) scaleY(.95)}90%{-webkit-transform:translate3d(0,-5px,0) scaleY(.985);transform:translate3d(0,-5px,0) scaleY(.985)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__bounceInUp{-webkit-animation-name:bounceInUp;animation-name:bounceInUp}@-webkit-keyframes bounceOut{20%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}50%,55%{opacity:1;-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}to{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}}@keyframes bounceOut{20%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}50%,55%{opacity:1;-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}to{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}}.animate__bounceOut{-webkit-animation-duration:.75s;animation-duration:.75s;-webkit-animation-duration:calc(var(--animate-duration)*0.75);animation-duration:calc(var(--animate-duration)*0.75);-webkit-animation-name:bounceOut;animation-name:bounceOut}@-webkit-keyframes bounceOutDown{20%{-webkit-transform:translate3d(0,10px,0) scaleY(.985);transform:translate3d(0,10px,0) scaleY(.985)}40%,45%{opacity:1;-webkit-transform:translate3d(0,-20px,0) scaleY(.9);transform:translate3d(0,-20px,0) scaleY(.9)}to{opacity:0;-webkit-transform:translate3d(0,2000px,0) scaleY(3);transform:translate3d(0,2000px,0) scaleY(3)}}@keyframes bounceOutDown{20%{-webkit-transform:translate3d(0,10px,0) scaleY(.985);transform:translate3d(0,10px,0) scaleY(.985)}40%,45%{opacity:1;-webkit-transform:translate3d(0,-20px,0) scaleY(.9);transform:translate3d(0,-20px,0) scaleY(.9)}to{opacity:0;-webkit-transform:translate3d(0,2000px,0) scaleY(3);transform:translate3d(0,2000px,0) scaleY(3)}}.animate__bounceOutDown{-webkit-animation-name:bounceOutDown;animation-name:bounceOutDown}@-webkit-keyframes bounceOutLeft{20%{opacity:1;-webkit-transform:translate3d(20px,0,0) scaleX(.9);transform:translate3d(20px,0,0) scaleX(.9)}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0) scaleX(2);transform:translate3d(-2000px,0,0) scaleX(2)}}@keyframes bounceOutLeft{20%{opacity:1;-webkit-transform:translate3d(20px,0,0) scaleX(.9);transform:translate3d(20px,0,0) scaleX(.9)}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0) scaleX(2);transform:translate3d(-2000px,0,0) scaleX(2)}}.animate__bounceOutLeft{-webkit-animation-name:bounceOutLeft;animation-name:bounceOutLeft}@-webkit-keyframes bounceOutRight{20%{opacity:1;-webkit-transform:translate3d(-20px,0,0) scaleX(.9);transform:translate3d(-20px,0,0) scaleX(.9)}to{opacity:0;-webkit-transform:translate3d(2000px,0,0) scaleX(2);transform:translate3d(2000px,0,0) scaleX(2)}}@keyframes bounceOutRight{20%{opacity:1;-webkit-transform:translate3d(-20px,0,0) scaleX(.9);transform:translate3d(-20px,0,0) scaleX(.9)}to{opacity:0;-webkit-transform:translate3d(2000px,0,0) scaleX(2);transform:translate3d(2000px,0,0) scaleX(2)}}.animate__bounceOutRight{-webkit-animation-name:bounceOutRight;animation-name:bounceOutRight}@-webkit-keyframes bounceOutUp{20%{-webkit-transform:translate3d(0,-10px,0) scaleY(.985);transform:translate3d(0,-10px,0) scaleY(.985)}40%,45%{opacity:1;-webkit-transform:translate3d(0,20px,0) scaleY(.9);transform:translate3d(0,20px,0) scaleY(.9)}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0) scaleY(3);transform:translate3d(0,-2000px,0) scaleY(3)}}@keyframes bounceOutUp{20%{-webkit-transform:translate3d(0,-10px,0) scaleY(.985);transform:translate3d(0,-10px,0) scaleY(.985)}40%,45%{opacity:1;-webkit-transform:translate3d(0,20px,0) scaleY(.9);transform:translate3d(0,20px,0) scaleY(.9)}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0) scaleY(3);transform:translate3d(0,-2000px,0) scaleY(3)}}.animate__bounceOutUp{-webkit-animation-name:bounceOutUp;animation-name:bounceOutUp}@-webkit-keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}.animate__fadeIn{-webkit-animation-name:fadeIn;animation-name:fadeIn}@-webkit-keyframes fadeInDown{0%{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInDown{0%{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInDown{-webkit-animation-name:fadeInDown;animation-name:fadeInDown}@-webkit-keyframes fadeInDownBig{0%{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInDownBig{0%{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInDownBig{-webkit-animation-name:fadeInDownBig;animation-name:fadeInDownBig}@-webkit-keyframes fadeInLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInLeft{-webkit-animation-name:fadeInLeft;animation-name:fadeInLeft}@-webkit-keyframes fadeInLeftBig{0%{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInLeftBig{0%{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInLeftBig{-webkit-animation-name:fadeInLeftBig;animation-name:fadeInLeftBig}@-webkit-keyframes fadeInRight{0%{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInRight{0%{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInRight{-webkit-animation-name:fadeInRight;animation-name:fadeInRight}@-webkit-keyframes fadeInRightBig{0%{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInRightBig{0%{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInRightBig{-webkit-animation-name:fadeInRightBig;animation-name:fadeInRightBig}@-webkit-keyframes fadeInUp{0%{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInUp{0%{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInUp{-webkit-animation-name:fadeInUp;animation-name:fadeInUp}@-webkit-keyframes fadeInUpBig{0%{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInUpBig{0%{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInUpBig{-webkit-animation-name:fadeInUpBig;animation-name:fadeInUpBig}@-webkit-keyframes fadeInTopLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,-100%,0);transform:translate3d(-100%,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInTopLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,-100%,0);transform:translate3d(-100%,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInTopLeft{-webkit-animation-name:fadeInTopLeft;animation-name:fadeInTopLeft}@-webkit-keyframes fadeInTopRight{0%{opacity:0;-webkit-transform:translate3d(100%,-100%,0);transform:translate3d(100%,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInTopRight{0%{opacity:0;-webkit-transform:translate3d(100%,-100%,0);transform:translate3d(100%,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInTopRight{-webkit-animation-name:fadeInTopRight;animation-name:fadeInTopRight}@-webkit-keyframes fadeInBottomLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,100%,0);transform:translate3d(-100%,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInBottomLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,100%,0);transform:translate3d(-100%,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInBottomLeft{-webkit-animation-name:fadeInBottomLeft;animation-name:fadeInBottomLeft}@-webkit-keyframes fadeInBottomRight{0%{opacity:0;-webkit-transform:translate3d(100%,100%,0);transform:translate3d(100%,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInBottomRight{0%{opacity:0;-webkit-transform:translate3d(100%,100%,0);transform:translate3d(100%,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInBottomRight{-webkit-animation-name:fadeInBottomRight;animation-name:fadeInBottomRight}@-webkit-keyframes fadeOut{0%{opacity:1}to{opacity:0}}@keyframes fadeOut{0%{opacity:1}to{opacity:0}}.animate__fadeOut{-webkit-animation-name:fadeOut;animation-name:fadeOut}@-webkit-keyframes fadeOutDown{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}@keyframes fadeOutDown{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}.animate__fadeOutDown{-webkit-animation-name:fadeOutDown;animation-name:fadeOutDown}@-webkit-keyframes fadeOutDownBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}@keyframes fadeOutDownBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}.animate__fadeOutDownBig{-webkit-animation-name:fadeOutDownBig;animation-name:fadeOutDownBig}@-webkit-keyframes fadeOutLeft{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}@keyframes fadeOutLeft{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}.animate__fadeOutLeft{-webkit-animation-name:fadeOutLeft;animation-name:fadeOutLeft}@-webkit-keyframes fadeOutLeftBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}@keyframes fadeOutLeftBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}.animate__fadeOutLeftBig{-webkit-animation-name:fadeOutLeftBig;animation-name:fadeOutLeftBig}@-webkit-keyframes fadeOutRight{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}@keyframes fadeOutRight{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}.animate__fadeOutRight{-webkit-animation-name:fadeOutRight;animation-name:fadeOutRight}@-webkit-keyframes fadeOutRightBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}@keyframes fadeOutRightBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}.animate__fadeOutRightBig{-webkit-animation-name:fadeOutRightBig;animation-name:fadeOutRightBig}@-webkit-keyframes fadeOutUp{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}@keyframes fadeOutUp{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}.animate__fadeOutUp{-webkit-animation-name:fadeOutUp;animation-name:fadeOutUp}@-webkit-keyframes fadeOutUpBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}@keyframes fadeOutUpBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}.animate__fadeOutUpBig{-webkit-animation-name:fadeOutUpBig;animation-name:fadeOutUpBig}@-webkit-keyframes fadeOutTopLeft{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(-100%,-100%,0);transform:translate3d(-100%,-100%,0)}}@keyframes fadeOutTopLeft{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(-100%,-100%,0);transform:translate3d(-100%,-100%,0)}}.animate__fadeOutTopLeft{-webkit-animation-name:fadeOutTopLeft;animation-name:fadeOutTopLeft}@-webkit-keyframes fadeOutTopRight{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(100%,-100%,0);transform:translate3d(100%,-100%,0)}}@keyframes fadeOutTopRight{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(100%,-100%,0);transform:translate3d(100%,-100%,0)}}.animate__fadeOutTopRight{-webkit-animation-name:fadeOutTopRight;animation-name:fadeOutTopRight}@-webkit-keyframes fadeOutBottomRight{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(100%,100%,0);transform:translate3d(100%,100%,0)}}@keyframes fadeOutBottomRight{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(100%,100%,0);transform:translate3d(100%,100%,0)}}.animate__fadeOutBottomRight{-webkit-animation-name:fadeOutBottomRight;animation-name:fadeOutBottomRight}@-webkit-keyframes fadeOutBottomLeft{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(-100%,100%,0);transform:translate3d(-100%,100%,0)}}@keyframes fadeOutBottomLeft{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(-100%,100%,0);transform:translate3d(-100%,100%,0)}}.animate__fadeOutBottomLeft{-webkit-animation-name:fadeOutBottomLeft;animation-name:fadeOutBottomLeft}@-webkit-keyframes flip{0%{-webkit-transform:perspective(400px) scaleX(1) translateZ(0) rotateY(-1turn);transform:perspective(400px) scaleX(1) translateZ(0) rotateY(-1turn);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}40%{-webkit-transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-190deg);transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-190deg);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}50%{-webkit-transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-170deg);transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-170deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}80%{-webkit-transform:perspective(400px) scale3d(.95,.95,.95) translateZ(0) rotateY(0deg);transform:perspective(400px) scale3d(.95,.95,.95) translateZ(0) rotateY(0deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}to{-webkit-transform:perspective(400px) scaleX(1) translateZ(0) rotateY(0deg);transform:perspective(400px) scaleX(1) translateZ(0) rotateY(0deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}}@keyframes flip{0%{-webkit-transform:perspective(400px) scaleX(1) translateZ(0) rotateY(-1turn);transform:perspective(400px) scaleX(1) translateZ(0) rotateY(-1turn);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}40%{-webkit-transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-190deg);transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-190deg);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}50%{-webkit-transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-170deg);transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-170deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}80%{-webkit-transform:perspective(400px) scale3d(.95,.95,.95) translateZ(0) rotateY(0deg);transform:perspective(400px) scale3d(.95,.95,.95) translateZ(0) rotateY(0deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}to{-webkit-transform:perspective(400px) scaleX(1) translateZ(0) rotateY(0deg);transform:perspective(400px) scaleX(1) translateZ(0) rotateY(0deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}}.animate__animated.animate__flip{-webkit-backface-visibility:visible;backface-visibility:visible;-webkit-animation-name:flip;animation-name:flip}@-webkit-keyframes flipInX{0%{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in;opacity:0}40%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}60%{-webkit-transform:perspective(400px) rotateX(10deg);transform:perspective(400px) rotateX(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateX(-5deg);transform:perspective(400px) rotateX(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}@keyframes flipInX{0%{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in;opacity:0}40%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}60%{-webkit-transform:perspective(400px) rotateX(10deg);transform:perspective(400px) rotateX(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateX(-5deg);transform:perspective(400px) rotateX(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}.animate__flipInX{-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipInX;animation-name:flipInX}@-webkit-keyframes flipInY{0%{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in;opacity:0}40%{-webkit-transform:perspective(400px) rotateY(-20deg);transform:perspective(400px) rotateY(-20deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}60%{-webkit-transform:perspective(400px) rotateY(10deg);transform:perspective(400px) rotateY(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateY(-5deg);transform:perspective(400px) rotateY(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}@keyframes flipInY{0%{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in;opacity:0}40%{-webkit-transform:perspective(400px) rotateY(-20deg);transform:perspective(400px) rotateY(-20deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}60%{-webkit-transform:perspective(400px) rotateY(10deg);transform:perspective(400px) rotateY(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateY(-5deg);transform:perspective(400px) rotateY(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}.animate__flipInY{-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipInY;animation-name:flipInY}@-webkit-keyframes flipOutX{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);opacity:1}to{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}}@keyframes flipOutX{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);opacity:1}to{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}}.animate__flipOutX{-webkit-animation-duration:.75s;animation-duration:.75s;-webkit-animation-duration:calc(var(--animate-duration)*0.75);animation-duration:calc(var(--animate-duration)*0.75);-webkit-animation-name:flipOutX;animation-name:flipOutX;-webkit-backface-visibility:visible!important;backface-visibility:visible!important}@-webkit-keyframes flipOutY{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateY(-15deg);transform:perspective(400px) rotateY(-15deg);opacity:1}to{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}}@keyframes flipOutY{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateY(-15deg);transform:perspective(400px) rotateY(-15deg);opacity:1}to{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}}.animate__flipOutY{-webkit-animation-duration:.75s;animation-duration:.75s;-webkit-animation-duration:calc(var(--animate-duration)*0.75);animation-duration:calc(var(--animate-duration)*0.75);-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipOutY;animation-name:flipOutY}@-webkit-keyframes lightSpeedInRight{0%{-webkit-transform:translate3d(100%,0,0) skewX(-30deg);transform:translate3d(100%,0,0) skewX(-30deg);opacity:0}60%{-webkit-transform:skewX(20deg);transform:skewX(20deg);opacity:1}80%{-webkit-transform:skewX(-5deg);transform:skewX(-5deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes lightSpeedInRight{0%{-webkit-transform:translate3d(100%,0,0) skewX(-30deg);transform:translate3d(100%,0,0) skewX(-30deg);opacity:0}60%{-webkit-transform:skewX(20deg);transform:skewX(20deg);opacity:1}80%{-webkit-transform:skewX(-5deg);transform:skewX(-5deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__lightSpeedInRight{-webkit-animation-name:lightSpeedInRight;animation-name:lightSpeedInRight;-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}@-webkit-keyframes lightSpeedInLeft{0%{-webkit-transform:translate3d(-100%,0,0) skewX(30deg);transform:translate3d(-100%,0,0) skewX(30deg);opacity:0}60%{-webkit-transform:skewX(-20deg);transform:skewX(-20deg);opacity:1}80%{-webkit-transform:skewX(5deg);transform:skewX(5deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes lightSpeedInLeft{0%{-webkit-transform:translate3d(-100%,0,0) skewX(30deg);transform:translate3d(-100%,0,0) skewX(30deg);opacity:0}60%{-webkit-transform:skewX(-20deg);transform:skewX(-20deg);opacity:1}80%{-webkit-transform:skewX(5deg);transform:skewX(5deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__lightSpeedInLeft{-webkit-animation-name:lightSpeedInLeft;animation-name:lightSpeedInLeft;-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}@-webkit-keyframes lightSpeedOutRight{0%{opacity:1}to{-webkit-transform:translate3d(100%,0,0) skewX(30deg);transform:translate3d(100%,0,0) skewX(30deg);opacity:0}}@keyframes lightSpeedOutRight{0%{opacity:1}to{-webkit-transform:translate3d(100%,0,0) skewX(30deg);transform:translate3d(100%,0,0) skewX(30deg);opacity:0}}.animate__lightSpeedOutRight{-webkit-animation-name:lightSpeedOutRight;animation-name:lightSpeedOutRight;-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}@-webkit-keyframes lightSpeedOutLeft{0%{opacity:1}to{-webkit-transform:translate3d(-100%,0,0) skewX(-30deg);transform:translate3d(-100%,0,0) skewX(-30deg);opacity:0}}@keyframes lightSpeedOutLeft{0%{opacity:1}to{-webkit-transform:translate3d(-100%,0,0) skewX(-30deg);transform:translate3d(-100%,0,0) skewX(-30deg);opacity:0}}.animate__lightSpeedOutLeft{-webkit-animation-name:lightSpeedOutLeft;animation-name:lightSpeedOutLeft;-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}@-webkit-keyframes rotateIn{0%{-webkit-transform:rotate(-200deg);transform:rotate(-200deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateIn{0%{-webkit-transform:rotate(-200deg);transform:rotate(-200deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.animate__rotateIn{-webkit-animation-name:rotateIn;animation-name:rotateIn;-webkit-transform-origin:center;transform-origin:center}@-webkit-keyframes rotateInDownLeft{0%{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateInDownLeft{0%{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.animate__rotateInDownLeft{-webkit-animation-name:rotateInDownLeft;animation-name:rotateInDownLeft;-webkit-transform-origin:left bottom;transform-origin:left bottom}@-webkit-keyframes rotateInDownRight{0%{-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateInDownRight{0%{-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.animate__rotateInDownRight{-webkit-animation-name:rotateInDownRight;animation-name:rotateInDownRight;-webkit-transform-origin:right bottom;transform-origin:right bottom}@-webkit-keyframes rotateInUpLeft{0%{-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateInUpLeft{0%{-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.animate__rotateInUpLeft{-webkit-animation-name:rotateInUpLeft;animation-name:rotateInUpLeft;-webkit-transform-origin:left bottom;transform-origin:left bottom}@-webkit-keyframes rotateInUpRight{0%{-webkit-transform:rotate(-90deg);transform:rotate(-90deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateInUpRight{0%{-webkit-transform:rotate(-90deg);transform:rotate(-90deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.animate__rotateInUpRight{-webkit-animation-name:rotateInUpRight;animation-name:rotateInUpRight;-webkit-transform-origin:right bottom;transform-origin:right bottom}@-webkit-keyframes rotateOut{0%{opacity:1}to{-webkit-transform:rotate(200deg);transform:rotate(200deg);opacity:0}}@keyframes rotateOut{0%{opacity:1}to{-webkit-transform:rotate(200deg);transform:rotate(200deg);opacity:0}}.animate__rotateOut{-webkit-animation-name:rotateOut;animation-name:rotateOut;-webkit-transform-origin:center;transform-origin:center}@-webkit-keyframes rotateOutDownLeft{0%{opacity:1}to{-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}}@keyframes rotateOutDownLeft{0%{opacity:1}to{-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}}.animate__rotateOutDownLeft{-webkit-animation-name:rotateOutDownLeft;animation-name:rotateOutDownLeft;-webkit-transform-origin:left bottom;transform-origin:left bottom}@-webkit-keyframes rotateOutDownRight{0%{opacity:1}to{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}@keyframes rotateOutDownRight{0%{opacity:1}to{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}.animate__rotateOutDownRight{-webkit-animation-name:rotateOutDownRight;animation-name:rotateOutDownRight;-webkit-transform-origin:right bottom;transform-origin:right bottom}@-webkit-keyframes rotateOutUpLeft{0%{opacity:1}to{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}@keyframes rotateOutUpLeft{0%{opacity:1}to{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}.animate__rotateOutUpLeft{-webkit-animation-name:rotateOutUpLeft;animation-name:rotateOutUpLeft;-webkit-transform-origin:left bottom;transform-origin:left bottom}@-webkit-keyframes rotateOutUpRight{0%{opacity:1}to{-webkit-transform:rotate(90deg);transform:rotate(90deg);opacity:0}}@keyframes rotateOutUpRight{0%{opacity:1}to{-webkit-transform:rotate(90deg);transform:rotate(90deg);opacity:0}}.animate__rotateOutUpRight{-webkit-animation-name:rotateOutUpRight;animation-name:rotateOutUpRight;-webkit-transform-origin:right bottom;transform-origin:right bottom}@-webkit-keyframes hinge{0%{-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}20%,60%{-webkit-transform:rotate(80deg);transform:rotate(80deg);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}40%,80%{-webkit-transform:rotate(60deg);transform:rotate(60deg);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;opacity:1}to{-webkit-transform:translate3d(0,700px,0);transform:translate3d(0,700px,0);opacity:0}}@keyframes hinge{0%{-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}20%,60%{-webkit-transform:rotate(80deg);transform:rotate(80deg);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}40%,80%{-webkit-transform:rotate(60deg);transform:rotate(60deg);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;opacity:1}to{-webkit-transform:translate3d(0,700px,0);transform:translate3d(0,700px,0);opacity:0}}.animate__hinge{-webkit-animation-duration:2s;animation-duration:2s;-webkit-animation-duration:calc(var(--animate-duration)*2);animation-duration:calc(var(--animate-duration)*2);-webkit-animation-name:hinge;animation-name:hinge;-webkit-transform-origin:top left;transform-origin:top left}@-webkit-keyframes jackInTheBox{0%{opacity:0;-webkit-transform:scale(.1) rotate(30deg);transform:scale(.1) rotate(30deg);-webkit-transform-origin:center bottom;transform-origin:center bottom}50%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}70%{-webkit-transform:rotate(3deg);transform:rotate(3deg)}to{opacity:1;-webkit-transform:scale(1);transform:scale(1)}}@keyframes jackInTheBox{0%{opacity:0;-webkit-transform:scale(.1) rotate(30deg);transform:scale(.1) rotate(30deg);-webkit-transform-origin:center bottom;transform-origin:center bottom}50%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}70%{-webkit-transform:rotate(3deg);transform:rotate(3deg)}to{opacity:1;-webkit-transform:scale(1);transform:scale(1)}}.animate__jackInTheBox{-webkit-animation-name:jackInTheBox;animation-name:jackInTheBox}@-webkit-keyframes rollIn{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0) rotate(-120deg);transform:translate3d(-100%,0,0) rotate(-120deg)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes rollIn{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0) rotate(-120deg);transform:translate3d(-100%,0,0) rotate(-120deg)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__rollIn{-webkit-animation-name:rollIn;animation-name:rollIn}@-webkit-keyframes rollOut{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0) rotate(120deg);transform:translate3d(100%,0,0) rotate(120deg)}}@keyframes rollOut{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0) rotate(120deg);transform:translate3d(100%,0,0) rotate(120deg)}}.animate__rollOut{-webkit-animation-name:rollOut;animation-name:rollOut}@-webkit-keyframes zoomIn{0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%{opacity:1}}@keyframes zoomIn{0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%{opacity:1}}.animate__zoomIn{-webkit-animation-name:zoomIn;animation-name:zoomIn}@-webkit-keyframes zoomInDown{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInDown{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.animate__zoomInDown{-webkit-animation-name:zoomInDown;animation-name:zoomInDown}@-webkit-keyframes zoomInLeft{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(10px,0,0);transform:scale3d(.475,.475,.475) translate3d(10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInLeft{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(10px,0,0);transform:scale3d(.475,.475,.475) translate3d(10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.animate__zoomInLeft{-webkit-animation-name:zoomInLeft;animation-name:zoomInLeft}@-webkit-keyframes zoomInRight{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInRight{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.animate__zoomInRight{-webkit-animation-name:zoomInRight;animation-name:zoomInRight}@-webkit-keyframes zoomInUp{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInUp{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.animate__zoomInUp{-webkit-animation-name:zoomInUp;animation-name:zoomInUp}@-webkit-keyframes zoomOut{0%{opacity:1}50%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}to{opacity:0}}@keyframes zoomOut{0%{opacity:1}50%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}to{opacity:0}}.animate__zoomOut{-webkit-animation-name:zoomOut;animation-name:zoomOut}@-webkit-keyframes zoomOutDown{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomOutDown{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.animate__zoomOutDown{-webkit-animation-name:zoomOutDown;animation-name:zoomOutDown;-webkit-transform-origin:center bottom;transform-origin:center bottom}@-webkit-keyframes zoomOutLeft{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(42px,0,0);transform:scale3d(.475,.475,.475) translate3d(42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(-2000px,0,0);transform:scale(.1) translate3d(-2000px,0,0)}}@keyframes zoomOutLeft{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(42px,0,0);transform:scale3d(.475,.475,.475) translate3d(42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(-2000px,0,0);transform:scale(.1) translate3d(-2000px,0,0)}}.animate__zoomOutLeft{-webkit-animation-name:zoomOutLeft;animation-name:zoomOutLeft;-webkit-transform-origin:left center;transform-origin:left center}@-webkit-keyframes zoomOutRight{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-42px,0,0);transform:scale3d(.475,.475,.475) translate3d(-42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(2000px,0,0);transform:scale(.1) translate3d(2000px,0,0)}}@keyframes zoomOutRight{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-42px,0,0);transform:scale3d(.475,.475,.475) translate3d(-42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(2000px,0,0);transform:scale(.1) translate3d(2000px,0,0)}}.animate__zoomOutRight{-webkit-animation-name:zoomOutRight;animation-name:zoomOutRight;-webkit-transform-origin:right center;transform-origin:right center}@-webkit-keyframes zoomOutUp{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomOutUp{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.animate__zoomOutUp{-webkit-animation-name:zoomOutUp;animation-name:zoomOutUp;-webkit-transform-origin:center bottom;transform-origin:center bottom}@-webkit-keyframes slideInDown{0%{-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInDown{0%{-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__slideInDown{-webkit-animation-name:slideInDown;animation-name:slideInDown}@-webkit-keyframes slideInLeft{0%{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInLeft{0%{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__slideInLeft{-webkit-animation-name:slideInLeft;animation-name:slideInLeft}@-webkit-keyframes slideInRight{0%{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInRight{0%{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__slideInRight{-webkit-animation-name:slideInRight;animation-name:slideInRight}@-webkit-keyframes slideInUp{0%{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInUp{0%{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__slideInUp{-webkit-animation-name:slideInUp;animation-name:slideInUp}@-webkit-keyframes slideOutDown{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}@keyframes slideOutDown{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}.animate__slideOutDown{-webkit-animation-name:slideOutDown;animation-name:slideOutDown}@-webkit-keyframes slideOutLeft{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}@keyframes slideOutLeft{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}.animate__slideOutLeft{-webkit-animation-name:slideOutLeft;animation-name:slideOutLeft}@-webkit-keyframes slideOutRight{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}@keyframes slideOutRight{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}.animate__slideOutRight{-webkit-animation-name:slideOutRight;animation-name:slideOutRight}@-webkit-keyframes slideOutUp{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}@keyframes slideOutUp{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}.animate__slideOutUp{-webkit-animation-name:slideOutUp;animation-name:slideOutUp}
\ No newline at end of file
diff --git a/src/player/render.js b/src/player/render.js
index 841778d..80477e6 100644
--- a/src/player/render.js
+++ b/src/player/render.js
@@ -120,6 +120,7 @@ function renderPlayerPage(slug, initialData) {
const hlsScriptTag = '';
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
+ const bodyClass = [initialData && initialData.thumbnailPreview ? 'thumbnail-preview' : '', ''].join(' ').trim();
const script = getPlayerPageScript()({
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
@@ -128,6 +129,7 @@ function renderPlayerPage(slug, initialData) {
return renderPage(template, {
title: 'Screen ' + slug,
+ bodyClass: bodyClass,
body: '',
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '' + '' + '' + '' + '' + '' + '' + '' + onboardingScript + script + ''
diff --git a/src/web/handlebars.js b/src/web/handlebars.js
index e95b676..7b484a9 100644
--- a/src/web/handlebars.js
+++ b/src/web/handlebars.js
@@ -160,6 +160,7 @@ function registerPartials(Handlebars, viewsRoot) {
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
Handlebars.registerPartial('signage/playlists/schedule-rule-card', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'));
+ Handlebars.registerPartial('signage/templates/animation-advanced-modal', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'animation-advanced-modal.hbs'), 'utf8'));
Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8'));
Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8'));
}
diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css
index 0f5f00d..72032e7 100644
--- a/src/web/public/css/theme-custom.css
+++ b/src/web/public/css/theme-custom.css
@@ -190,6 +190,82 @@
max-width: 14rem;
}
+.template-preview-card .card-body {
+ gap: 0.75rem 1rem;
+}
+
+.template-preview-card.is-previewing {
+ border-color: rgba(var(--bs-primary-rgb), 0.45);
+}
+
+.template-preview-card.is-previewing .card-header {
+ position: relative;
+}
+
+.template-preview-card.is-previewing .card-title {
+ position: relative;
+ padding-right: 6.25rem;
+}
+
+.template-preview-card.is-previewing .card-title::after {
+ content: ' previewing';
+ position: absolute;
+ top: 50%;
+ left: calc(100% + 0.35rem);
+ color: var(--bs-primary);
+ font-size: 0.8em;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+ transform: translateY(-50%);
+}
+
+.template-preview-card .btn-group {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.5rem;
+}
+
+.template-preview-card .btn-group .btn {
+ width: 100%;
+}
+
+.template-preview-card[hidden] {
+ display: none !important;
+}
+
+.template-designer-form--previewing .designer-stage {
+ box-shadow: none !important;
+ overflow: hidden;
+}
+
+.template-designer-form--previewing .designer-overlay {
+ cursor: progress;
+}
+
+.template-designer-form--previewing .designer-rect {
+ transition: none !important;
+}
+
+.template-designer-form--previewing .resize-handle {
+ display: none;
+}
+
+.template-designer-form--previewing .region-item,
+.template-designer-form--previewing .template-details-card,
+.template-designer-form--previewing .template-options-card,
+.template-designer-form--previewing .region-info-card {
+ opacity: 0.84;
+}
+
+.template-designer-form--previewing .region-item [disabled],
+.template-designer-form--previewing .template-details-card [disabled],
+.template-designer-form--previewing .template-options-card [disabled],
+.template-designer-form--previewing .region-info-card [disabled] {
+ cursor: not-allowed;
+}
+
.announcement-icon-picker {
position: relative;
}
@@ -1401,7 +1477,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
position: relative;
width: 100%;
min-height: 18rem;
- /* overflow: hidden; */
+ overflow: visible;
border: 1px solid var(--bs-border-color);
border-radius: 0;
background: var(--bs-body-bg);
@@ -1431,11 +1507,16 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.designer-overlay {
z-index: 2;
cursor: default;
+ --designer-overlay-scale: 1;
}
.designer-rect {
position: absolute;
- border: 2px solid rgba(13, 110, 253, 0.95);
+ overflow: visible;
+ z-index: 3;
+ border: 0;
+ outline: 2px solid rgba(13, 110, 253, 0.95);
+ outline-offset: -2px;
background: rgba(13, 110, 253, 0.12);
box-sizing: border-box;
border-radius: 0;
@@ -1445,20 +1526,20 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
}
.designer-rect.selected {
- border-color: rgba(25, 135, 84, 1);
+ outline-color: rgba(25, 135, 84, 1);
background: rgba(25, 135, 84, 0.14);
}
.designer-rect-label {
position: absolute;
- left: 0.4rem;
- top: 0.35rem;
+ left: calc(6px * var(--designer-overlay-scale, 1));
+ top: calc(6px * var(--designer-overlay-scale, 1));
max-width: 100%;
- padding: 0.15rem 0.45rem;
+ padding: calc(2px * var(--designer-overlay-scale, 1)) calc(7px * var(--designer-overlay-scale, 1));
border-radius: 999px;
background: rgba(33, 37, 41, 0.92);
color: #fff;
- font-size: 0.75rem;
+ font-size: calc(0.75rem * var(--designer-overlay-scale, 1));
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
@@ -1471,8 +1552,10 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.resize-handle {
position: absolute;
- width: 0.8rem;
- height: 0.8rem;
+ z-index: 4;
+ width: 12px;
+ height: 12px;
+ box-sizing: border-box;
border-radius: 999px;
border: 2px solid #fff;
background: rgba(13, 110, 253, 1);
@@ -1482,28 +1565,28 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.resize-handle.nw {
left: 0;
top: 0;
- transform: translate(-50%, -50%);
+ transform: translate(-40%, -40%);
cursor: nwse-resize;
}
.resize-handle.ne {
right: 0;
top: 0;
- transform: translate(50%, -50%);
+ transform: translate(40%, -40%);
cursor: nesw-resize;
}
.resize-handle.sw {
left: 0;
bottom: 0;
- transform: translate(-50%, 50%);
+ transform: translate(-40%, 40%);
cursor: nesw-resize;
}
.resize-handle.se {
right: 0;
bottom: 0;
- transform: translate(50%, 50%);
+ transform: translate(40%, 40%);
cursor: nwse-resize;
}
@@ -1641,6 +1724,10 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
+.region-field-grid--animation {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
.region-field-grid > label {
display: grid;
gap: 0.35rem;
@@ -1877,7 +1964,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.region-field-grid--identity,
.region-field-grid--position,
- .region-field-grid--size {
+ .region-field-grid--size,
+ .region-field-grid--animation {
grid-template-columns: 1fr;
}
}
diff --git a/src/web/public/js/templates/animation-presets.js b/src/web/public/js/templates/animation-presets.js
new file mode 100644
index 0000000..f686a78
--- /dev/null
+++ b/src/web/public/js/templates/animation-presets.js
@@ -0,0 +1,203 @@
+(function (root, factory) {
+ var presets = factory();
+
+ if (typeof module === 'object' && module.exports) {
+ module.exports = presets;
+ }
+
+ if (root) {
+ root.templateAnimationPresets = presets;
+ }
+})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this), function () {
+ function option(value, label) {
+ return { value: value, label: label };
+ }
+
+ function formatLabel(value) {
+ return String(value || '')
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+ .replace(/[_-]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .replace(/\b\w/g, function (letter) {
+ return letter.toUpperCase();
+ });
+ }
+
+ function buildAllValues(groups, extraValues) {
+ var values = [];
+ var seen = Object.create(null);
+
+ function addEntry(entry) {
+ if (!entry || typeof entry !== 'object' || !entry.value || seen[entry.value]) {
+ return;
+ }
+
+ seen[entry.value] = true;
+ values.push(entry.value);
+ }
+
+ Object.keys(groups).forEach(function (groupName) {
+ (groups[groupName] || []).forEach(function (entry) {
+ if (Array.isArray(entry)) {
+ entry.forEach(addEntry);
+ return;
+ }
+
+ addEntry(entry);
+ });
+ });
+
+ (extraValues || []).forEach(function (value) {
+ if (!value || seen[value]) {
+ return;
+ }
+
+ seen[value] = true;
+ values.push(value);
+ });
+
+ return values;
+ }
+
+ function buildLookup(values) {
+ var lookup = Object.create(null);
+
+ values.forEach(function (value) {
+ var key = String(value || '').trim().toLowerCase();
+ if (key && !lookup[key]) {
+ lookup[key] = value;
+ }
+ });
+
+ return lookup;
+ }
+
+ function normalizeValue(value, lookup) {
+ var raw = String(value || '').trim();
+ if (!raw) {
+ return 'none';
+ }
+
+ return lookup[raw.toLowerCase()] || 'none';
+ }
+
+ function splitAnimateCssValues(values) {
+ var groups = {
+ intro: [],
+ outro: [],
+ attentionSeekers: []
+ };
+
+ (values || []).forEach(function (value) {
+ if (!value || value === 'none') {
+ return;
+ }
+
+ if (value.indexOf('In') !== -1) {
+ groups.intro.push(value);
+ return;
+ }
+
+ if (value.indexOf('Out') !== -1) {
+ groups.outro.push(value);
+ return;
+ }
+
+ groups.attentionSeekers.push(value);
+ });
+
+ return groups;
+ }
+
+ function buildCuratedBasic(values) {
+ return [option('none', 'None')].concat(values.map(function (value) {
+ return option(value, formatLabel(value));
+ }));
+ }
+
+ var animateCssValues = [
+ 'none',
+ 'bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat',
+ 'backInDown', 'backInLeft', 'backInRight', 'backInUp',
+ 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp',
+ 'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp',
+ 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp',
+ 'fadeIn', 'fadeInDown', 'fadeInDownBig', 'fadeInLeft', 'fadeInLeftBig', 'fadeInRight', 'fadeInRightBig', 'fadeInUp', 'fadeInUpBig', 'fadeInTopLeft', 'fadeInTopRight', 'fadeInBottomLeft', 'fadeInBottomRight',
+ 'fadeOut', 'fadeOutDown', 'fadeOutDownBig', 'fadeOutLeft', 'fadeOutLeftBig', 'fadeOutRight', 'fadeOutRightBig', 'fadeOutUp', 'fadeOutUpBig', 'fadeOutTopLeft', 'fadeOutTopRight', 'fadeOutBottomLeft', 'fadeOutBottomRight',
+ 'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY',
+ 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight',
+ 'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight',
+ 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight',
+ 'hinge', 'jackInTheBox', 'rollIn', 'rollOut',
+ 'zoomIn', 'zoomInDown', 'zoomInLeft', 'zoomInRight', 'zoomInUp',
+ 'zoomOut', 'zoomOutDown', 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp',
+ 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp',
+ 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp'
+ ];
+
+ var splitValues = splitAnimateCssValues(animateCssValues);
+
+ var basic = {
+ intro: [
+ option('none', 'None'),
+ option('fadeIn', formatLabel('fadeIn')),
+ option('fadeInDown', formatLabel('fadeInDown')),
+ option('fadeInLeft', formatLabel('fadeInLeft')),
+ option('fadeInRight', formatLabel('fadeInRight')),
+ option('fadeInUp', formatLabel('fadeInUp')),
+ option('zoomIn', formatLabel('zoomIn')),
+ option('zoomInDown', formatLabel('zoomInDown')),
+ option('zoomInLeft', formatLabel('zoomInLeft')),
+ option('zoomInRight', formatLabel('zoomInRight')),
+ option('zoomInUp', formatLabel('zoomInUp')),
+ option('slideInDown', formatLabel('slideInDown')),
+ option('slideInLeft', formatLabel('slideInLeft')),
+ option('slideInRight', formatLabel('slideInRight')),
+ option('slideInUp', formatLabel('slideInUp'))
+ ],
+ outro: [
+ option('none', 'None'),
+ option('fadeOut', formatLabel('fadeOut')),
+ option('fadeOutDown', formatLabel('fadeOutDown')),
+ option('fadeOutLeft', formatLabel('fadeOutLeft')),
+ option('fadeOutRight', formatLabel('fadeOutRight')),
+ option('fadeOutUp', formatLabel('fadeOutUp')),
+ option('zoomOut', formatLabel('zoomOut')),
+ option('zoomOutDown', formatLabel('zoomOutDown')),
+ option('zoomOutLeft', formatLabel('zoomOutLeft')),
+ option('zoomOutRight', formatLabel('zoomOutRight')),
+ option('zoomOutUp', formatLabel('zoomOutUp')),
+ option('slideOutDown', formatLabel('slideOutDown')),
+ option('slideOutLeft', formatLabel('slideOutLeft')),
+ option('slideOutRight', formatLabel('slideOutRight')),
+ option('slideOutUp', formatLabel('slideOutUp'))
+ ],
+ attentionSeekers: buildCuratedBasic(['flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce'])
+ };
+
+ var advanced = {
+ intro: buildCuratedBasic(splitValues.intro),
+ outro: buildCuratedBasic(splitValues.outro),
+ attentionSeekers: buildCuratedBasic(splitValues.attentionSeekers)
+ };
+
+ var lookup = buildLookup(buildAllValues({ basic: [basic.intro, basic.outro, basic.attentionSeekers], advanced: [advanced.intro, advanced.outro, advanced.attentionSeekers] }));
+
+ function normalize(value) {
+ return normalizeValue(value, lookup);
+ }
+
+ function isAnimateCssPreset(value) {
+ return animateCssValues.indexOf(normalizeValue(value, buildLookup(animateCssValues))) !== -1;
+ }
+
+ return {
+ basic: basic,
+ advanced: advanced,
+ animateCssValues: animateCssValues,
+ allValues: buildAllValues({ basic: [basic.intro, basic.outro, basic.attentionSeekers], advanced: [advanced.intro, advanced.outro, advanced.attentionSeekers] }),
+ normalize: normalize,
+ isAnimateCssPreset: isAnimateCssPreset
+ };
+});
\ No newline at end of file
diff --git a/src/web/public/js/templates/template-designer-utils.js b/src/web/public/js/templates/template-designer-utils.js
index 8457ae8..8144e7a 100644
--- a/src/web/public/js/templates/template-designer-utils.js
+++ b/src/web/public/js/templates/template-designer-utils.js
@@ -82,6 +82,174 @@
return String(card.querySelector('[name="region_name[]"]').value || '').trim();
}
+ function getAnimationConfig(card) {
+ var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
+ var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
+ var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
+ var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
+ var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
+
+ if (introPresetInput && introPresetInput.dataset.animationPresetCustom !== '1' && introPresetInput.selectedIndex >= 0) {
+ animationConfig.intro.preset = normalizePreviewAnimation(introPresetInput.value);
+ }
+ if (outroPresetInput && outroPresetInput.dataset.animationPresetCustom !== '1' && outroPresetInput.selectedIndex >= 0) {
+ animationConfig.outro.preset = normalizePreviewAnimation(outroPresetInput.value);
+ }
+ if (loopPresetInput && loopPresetInput.dataset.animationPresetCustom !== '1' && loopPresetInput.selectedIndex >= 0) {
+ animationConfig.loop.preset = normalizePreviewAnimation(loopPresetInput.value);
+ }
+
+ return animationConfig;
+ }
+
+ function getAnimationPresetValues() {
+ var presets = window.templateAnimationPresets || {};
+ if (Array.isArray(presets.allValues) && presets.allValues.length) {
+ return presets.allValues;
+ }
+
+ return [
+ 'none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown',
+ 'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp',
+ 'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown',
+ 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp',
+ 'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp',
+ 'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp',
+ 'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight',
+ 'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight',
+ 'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce'
+ ];
+ }
+
+ function getAnimationPresetMap() {
+ var lookup = {};
+ getAnimationPresetValues().forEach(function (value) {
+ lookup[value] = true;
+ });
+ return lookup;
+ }
+
+ function syncAnimationPresetCustomState(selectInput, resetButton, preset) {
+ if (!selectInput) {
+ return;
+ }
+
+ var normalizedPreset = normalizePreviewAnimation(preset);
+ var hasMatchingOption = Array.prototype.some.call(selectInput.options || [], function (option) {
+ return option && option.value === normalizedPreset;
+ });
+
+ if (hasMatchingOption) {
+ delete selectInput.dataset.animationPresetCustom;
+ selectInput.hidden = false;
+ if (resetButton) {
+ resetButton.hidden = true;
+ }
+ } else {
+ selectInput.dataset.animationPresetCustom = '1';
+ selectInput.hidden = true;
+ if (resetButton) {
+ resetButton.hidden = false;
+ resetButton.textContent = 'Reset to basic';
+ }
+ }
+ }
+
+ function writeAnimationConfig(card, animationConfig) {
+ var normalized = normalizeAnimationConfig(animationConfig);
+ var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
+ var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
+ var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
+ var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
+ var introResetButton = card.querySelector('[data-animation-reset-button="intro"]');
+ var outroResetButton = card.querySelector('[data-animation-reset-button="outro"]');
+ var loopResetButton = card.querySelector('[data-animation-reset-button="loop"]');
+
+ if (hiddenAnimationInput) {
+ hiddenAnimationInput.value = JSON.stringify(normalized, null, 2);
+ }
+ if (introPresetInput) {
+ introPresetInput.value = normalized.intro.preset;
+ syncAnimationPresetCustomState(introPresetInput, introResetButton, normalized.intro.preset);
+ }
+ if (outroPresetInput) {
+ outroPresetInput.value = normalized.outro.preset;
+ syncAnimationPresetCustomState(outroPresetInput, outroResetButton, normalized.outro.preset);
+ }
+ if (loopPresetInput) {
+ loopPresetInput.value = normalized.loop.preset;
+ syncAnimationPresetCustomState(loopPresetInput, loopResetButton, normalized.loop.preset);
+ }
+
+ return normalized;
+ }
+
+ function normalizePreviewAnimation(value) {
+ if (window.templateAnimationPresets && typeof window.templateAnimationPresets.normalize === 'function') {
+ return window.templateAnimationPresets.normalize(value);
+ }
+
+ var allowed = getAnimationPresetMap();
+
+ var raw = String(value || '').trim().toLowerCase();
+ return allowed[raw] ? raw : 'none';
+ }
+
+ function normalizeAnimationStep(value, fallbackPreset) {
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ var normalized = {};
+ Object.keys(value).forEach(function (key) {
+ normalized[key] = value[key];
+ });
+ normalized.preset = normalizePreviewAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
+ normalized.duration_ms = Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null;
+ normalized.delay_ms = Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null;
+ normalized.iterations = Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null;
+ normalized.easing = String(value.easing || '').trim();
+ normalized.fill_mode = String(value.fill_mode || '').trim();
+ return normalized;
+ }
+
+ return {
+ preset: normalizePreviewAnimation(typeof value === 'string' ? value : fallbackPreset || 'none'),
+ duration_ms: null,
+ delay_ms: null,
+ iterations: null,
+ easing: '',
+ fill_mode: ''
+ };
+ }
+
+ function normalizeAnimationConfig(value) {
+ var raw = value;
+ if (typeof raw === 'string') {
+ var text = String(raw || '').trim();
+ if (!text) {
+ raw = null;
+ } else {
+ try {
+ raw = JSON.parse(text);
+ } catch (_error) {
+ raw = null;
+ }
+ }
+ }
+
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ raw = {};
+ }
+
+ return {
+ intro: normalizeAnimationStep(raw.intro, 'none'),
+ outro: normalizeAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
+ loop: normalizeAnimationStep(raw.loop, 'none')
+ };
+ }
+
+ function formatAnimationConfig(value) {
+ return JSON.stringify(normalizeAnimationConfig(value), null, 2);
+ }
+
function syncRegionIdentity(card, value) {
var next = String(value || '').trim();
card.querySelector('[name="region_name[]"]').value = next;
@@ -96,6 +264,7 @@
label: name,
region_type: card.querySelector('[name="region_type[]"]').value,
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
+ animation_json: getAnimationConfig(card),
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
@@ -114,6 +283,7 @@
}
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
+ if (values.animation_json !== undefined) { writeAnimationConfig(card, values.animation_json); }
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
@@ -171,6 +341,10 @@
syncRegionIdentity: syncRegionIdentity,
readCard: readCard,
writeCard: writeCard,
+ normalizeAnimationConfig: normalizeAnimationConfig,
+ formatAnimationConfig: formatAnimationConfig,
+ getAnimationConfig: getAnimationConfig,
+ writeAnimationConfig: writeAnimationConfig,
clampRegion: clampRegion,
getOverlayRect: getOverlayRect,
toCanvasPoint: toCanvasPoint,
diff --git a/src/web/public/js/templates/template-designer.js b/src/web/public/js/templates/template-designer.js
index 164ed23..9a48877 100644
--- a/src/web/public/js/templates/template-designer.js
+++ b/src/web/public/js/templates/template-designer.js
@@ -20,6 +20,10 @@
: (Array.isArray(templateData) ? templateData : []);
var stage = document.getElementById('designer-stage');
var overlay = document.getElementById('designer-overlay');
+ var previewCard = document.getElementById('template-preview-card');
+ var previewIntroButton = document.getElementById('template-preview-intro');
+ var previewOutButton = document.getElementById('template-preview-out');
+ var previewContinuousButton = document.getElementById('template-preview-continuous');
var regionList = document.getElementById('region-list');
var regionSelect = document.getElementById('region-select');
var canvasSizeSelect = document.getElementById('canvas-size-select');
@@ -36,6 +40,7 @@
var addRegionButton = document.getElementById('add-region-button');
var regionAddModal = document.getElementById('region-add-modal');
var regionAddOptions = document.getElementById('region-add-options');
+ var animationAdvancedModal = document.getElementById('animation-advanced-modal');
var regionCardTemplate = document.getElementById('region-card-template');
var regionsJsonInput = document.getElementById('regions-json');
var templateForm = document.getElementById('template-form');
@@ -43,7 +48,20 @@
var regionUsageSet = new Set();
var draft = null;
var selectedIndex = -1;
+ var activeAnimationCard = null;
var overlayRenderFrame = 0;
+ var previewState = {
+ active: false,
+ mode: '',
+ stopTimer: 0,
+ token: 0,
+ animations: []
+ };
+ var previewButtons = [
+ { button: previewIntroButton, mode: 'intro' },
+ { button: previewOutButton, mode: 'out' },
+ { button: previewContinuousButton, mode: 'continuous' }
+ ];
try {
var regionUsageData = JSON.parse((templateRegionUsageElement && templateRegionUsageElement.textContent) || '[]');
@@ -218,6 +236,7 @@
label: getRegionName(card),
region_type: card.querySelector('[name="region_type[]"]').value,
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
+ animation_json: getAnimationConfig(card),
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
@@ -226,6 +245,582 @@
};
}
+ function hasAnyAnimationCards() {
+ return getCards().some(function (card) {
+ var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
+ var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
+ return animationConfig && ['intro', 'outro', 'loop'].some(function (stepName) {
+ var step = animationConfig[stepName] || {};
+ return normalizePreviewAnimation(step.preset) !== 'none';
+ });
+ });
+ }
+
+ function hasPreviewAnimationForMode(mode) {
+ var previewMode = String(mode || '').trim().toLowerCase();
+ if (!previewMode) {
+ return false;
+ }
+
+ return getCards().some(function (card) {
+ var step = getPreviewRegionAnimationStep(readCard(card), previewMode);
+ return normalizePreviewAnimation(step && step.preset) !== 'none';
+ });
+ }
+
+ function getPreviewRects() {
+ if (!overlay) {
+ return [];
+ }
+
+ return Array.prototype.slice.call(overlay.querySelectorAll('.designer-rect'));
+ }
+
+ function clearPreviewAnimations() {
+ previewState.animations.forEach(function (animation) {
+ if (animation && typeof animation.cancel === 'function') {
+ animation.cancel();
+ }
+ });
+ previewState.animations = [];
+
+ getPreviewRects().forEach(function (rect) {
+ rect.classList.remove('animate__animated', 'animate__infinite');
+ rect.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
+ if (window.templateAnimationPresets && Array.isArray(window.templateAnimationPresets.animateCssValues)) {
+ window.templateAnimationPresets.animateCssValues.forEach(function (value) {
+ if (value && value !== 'none') {
+ rect.classList.remove('animate__' + value);
+ }
+ });
+ }
+ rect.style.removeProperty('--animate-duration');
+ rect.style.removeProperty('--animate-delay');
+ rect.style.removeProperty('--animate-repeat');
+ rect.style.opacity = '';
+ rect.style.transform = '';
+ rect.style.filter = '';
+ rect.style.willChange = '';
+ });
+ }
+
+ function updatePreviewControlState() {
+ var activeMode = previewState.active ? previewState.mode : '';
+
+ previewButtons.forEach(function (entry) {
+ if (!entry.button) {
+ return;
+ }
+
+ var available = hasPreviewAnimationForMode(entry.mode);
+ var disabled = !available || (previewState.active && activeMode !== entry.mode);
+ entry.button.disabled = disabled;
+ entry.button.classList.toggle('active', previewState.active && activeMode === entry.mode);
+ entry.button.setAttribute('aria-pressed', previewState.active && activeMode === entry.mode ? 'true' : 'false');
+ });
+ }
+
+ function normalizePreviewAnimation(value) {
+ if (window.templateAnimationPresets && typeof window.templateAnimationPresets.normalize === 'function') {
+ return window.templateAnimationPresets.normalize(value);
+ }
+
+ var allowed = {};
+ var presets = window.templateAnimationPresets || {};
+ var values = Array.isArray(presets.allValues) && presets.allValues.length ? presets.allValues : [
+ 'none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown',
+ 'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp',
+ 'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown',
+ 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp',
+ 'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp',
+ 'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp',
+ 'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight',
+ 'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight',
+ 'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce'
+ ];
+
+ values.forEach(function (valueName) {
+ allowed[valueName] = true;
+ });
+
+ var raw = String(value || '').trim().toLowerCase();
+ return allowed[raw] ? raw : 'none';
+ }
+
+ function isAnimateCssPreset(value) {
+ var presets = window.templateAnimationPresets || {};
+ if (Array.isArray(presets.animateCssValues) && presets.animateCssValues.length) {
+ return presets.animateCssValues.indexOf(normalizePreviewAnimation(value)) !== -1;
+ }
+
+ return false;
+ }
+
+ function applyAnimateCssPreview(rect, preset, step, mode) {
+ var animateClass = 'animate__' + preset;
+ var durationMs = Number(step && step.duration_ms);
+ var delayMs = Number(step && step.delay_ms);
+ var repeatCount = Number(step && step.iterations);
+ var isAttentionSeeker = preset === 'bounce' || preset === 'flash' || preset === 'pulse' || preset === 'rubberBand' || preset === 'shakeX' || preset === 'shakeY' || preset === 'headShake' || preset === 'swing' || preset === 'tada' || preset === 'wobble' || preset === 'jello' || preset === 'heartBeat';
+
+ rect.classList.remove('animate__animated', 'animate__infinite');
+ rect.style.removeProperty('--animate-duration');
+ rect.style.removeProperty('--animate-delay');
+ rect.style.removeProperty('--animate-repeat');
+ rect.style.setProperty('--animate-duration', Math.max(0, Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 1000) + 'ms');
+ if (Number.isFinite(delayMs) && delayMs > 0) {
+ rect.style.setProperty('--animate-delay', Math.max(0, delayMs) + 'ms');
+ }
+ rect.classList.add('animate__animated', animateClass);
+ if (isAttentionSeeker) {
+ var normalizedRepeatCount = Math.max(1, Number.isFinite(repeatCount) && repeatCount > 0 ? repeatCount : 1);
+ rect.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
+ rect.classList.add('animate__repeat-1');
+ rect.style.setProperty('--animate-repeat', String(normalizedRepeatCount));
+ }
+ }
+
+ function getPreviewAnimationStepTimingMs(step, mode, animateCssMode) {
+ var normalizedMode = String(mode || '').trim().toLowerCase();
+ var durationMs = Number(step && step.duration_ms);
+ var delayMs = Number(step && step.delay_ms);
+ var iterations = Number(step && step.iterations);
+
+ if (!Number.isFinite(durationMs) || durationMs <= 0) {
+ durationMs = animateCssMode ? 1000 : 900;
+ }
+ if (!Number.isFinite(delayMs) || delayMs < 0) {
+ delayMs = 0;
+ }
+ if (!Number.isFinite(iterations) || iterations < 1) {
+ iterations = 1;
+ }
+
+ if (normalizedMode === 'continuous') {
+ return delayMs + (durationMs * iterations);
+ }
+
+ return delayMs + durationMs;
+ }
+
+ function normalizeAnimationStep(value, fallbackPreset) {
+ if (utils.normalizeAnimationStep) {
+ return utils.normalizeAnimationStep(value, fallbackPreset);
+ }
+
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ var normalized = {};
+ Object.keys(value).forEach(function (key) {
+ normalized[key] = value[key];
+ });
+ normalized.preset = normalizePreviewAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
+ normalized.duration_ms = Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null;
+ normalized.delay_ms = Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null;
+ normalized.iterations = Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null;
+ normalized.fill_mode = String(value.fill_mode || '').trim();
+ return normalized;
+ }
+
+ return {
+ preset: normalizePreviewAnimation(typeof value === 'string' ? value : fallbackPreset || 'none'),
+ duration_ms: null,
+ delay_ms: null,
+ iterations: null,
+ fill_mode: ''
+ };
+ }
+
+ function normalizeAnimationConfig(value) {
+ if (utils.normalizeAnimationConfig) {
+ return utils.normalizeAnimationConfig(value);
+ }
+
+ var raw = value;
+ if (typeof raw === 'string') {
+ var text = String(raw || '').trim();
+ if (!text) {
+ raw = null;
+ } else {
+ try {
+ raw = JSON.parse(text);
+ } catch (_error) {
+ raw = null;
+ }
+ }
+ }
+
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
+ raw = {};
+ }
+
+ return {
+ intro: normalizeAnimationStep(raw.intro, 'none'),
+ outro: normalizeAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
+ loop: normalizeAnimationStep(raw.loop, 'none')
+ };
+ }
+
+ function getPreviewRegionAnimationStep(region, mode) {
+ if (!region) {
+ return normalizeAnimationStep({}, 'none');
+ }
+
+ var animationConfig = normalizeAnimationConfig(region.animation_json !== undefined ? region.animation_json : region);
+
+ if (mode === 'intro') {
+ return animationConfig.intro;
+ }
+
+ if (mode === 'out') {
+ return animationConfig.outro;
+ }
+
+ return animationConfig.loop;
+ }
+
+ function getAnimationConfig(card) {
+ var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
+ var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
+ var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
+ var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
+ var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
+
+ if (introPresetInput && introPresetInput.dataset.animationPresetCustom !== '1' && introPresetInput.selectedIndex >= 0) {
+ animationConfig.intro.preset = normalizePreviewAnimation(introPresetInput.value);
+ }
+ if (outroPresetInput && outroPresetInput.dataset.animationPresetCustom !== '1' && outroPresetInput.selectedIndex >= 0) {
+ animationConfig.outro.preset = normalizePreviewAnimation(outroPresetInput.value);
+ }
+ if (loopPresetInput && loopPresetInput.dataset.animationPresetCustom !== '1' && loopPresetInput.selectedIndex >= 0) {
+ animationConfig.loop.preset = normalizePreviewAnimation(loopPresetInput.value);
+ }
+
+ return animationConfig;
+ }
+
+ function syncAnimationPresetCustomState(selectInput, resetButton, preset) {
+ if (!selectInput) {
+ return;
+ }
+
+ var normalizedPreset = normalizePreviewAnimation(preset);
+ var hasMatchingOption = Array.prototype.some.call(selectInput.options || [], function (option) {
+ return option && option.value === normalizedPreset;
+ });
+
+ if (hasMatchingOption) {
+ delete selectInput.dataset.animationPresetCustom;
+ selectInput.hidden = false;
+ if (resetButton) {
+ resetButton.hidden = true;
+ }
+ } else {
+ selectInput.dataset.animationPresetCustom = '1';
+ selectInput.hidden = true;
+ if (resetButton) {
+ resetButton.hidden = false;
+ resetButton.textContent = 'Reset to basic';
+ }
+ }
+ }
+
+ function writeAnimationConfig(card, animationConfig) {
+ var normalized = normalizeAnimationConfig(animationConfig);
+ var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
+ var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
+ var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
+ var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
+ var introResetButton = card.querySelector('[data-animation-reset-button="intro"]');
+ var outroResetButton = card.querySelector('[data-animation-reset-button="outro"]');
+ var loopResetButton = card.querySelector('[data-animation-reset-button="loop"]');
+
+ if (hiddenAnimationInput) {
+ hiddenAnimationInput.value = JSON.stringify(normalized, null, 2);
+ }
+ if (introPresetInput) {
+ introPresetInput.value = normalized.intro.preset;
+ syncAnimationPresetCustomState(introPresetInput, introResetButton, normalized.intro.preset);
+ }
+ if (outroPresetInput) {
+ outroPresetInput.value = normalized.outro.preset;
+ syncAnimationPresetCustomState(outroPresetInput, outroResetButton, normalized.outro.preset);
+ }
+ if (loopPresetInput) {
+ loopPresetInput.value = normalized.loop.preset;
+ syncAnimationPresetCustomState(loopPresetInput, loopResetButton, normalized.loop.preset);
+ }
+
+ updatePreviewCardVisibility();
+
+ return normalized;
+ }
+
+ function setAnimationPreset(card, stepName, preset) {
+ var config = getAnimationConfig(card);
+ if (config[stepName]) {
+ config[stepName].preset = normalizePreviewAnimation(preset);
+ config[stepName].duration_ms = null;
+ config[stepName].delay_ms = null;
+ config[stepName].iterations = null;
+ config[stepName].easing = '';
+ config[stepName].fill_mode = '';
+ writeAnimationConfig(card, config);
+ if (activeAnimationCard === card) {
+ populateAnimationModal(card);
+ }
+ }
+ }
+
+ function getAnimationModalField(stepName, fieldName) {
+ if (!animationAdvancedModal) {
+ return null;
+ }
+
+ return animationAdvancedModal.querySelector('[data-animation-modal-step="' + stepName + '"][data-animation-modal-field="' + fieldName + '"]');
+ }
+
+ function populateAnimationModal(card) {
+ if (!animationAdvancedModal || !card) {
+ return;
+ }
+
+ var title = animationAdvancedModal.querySelector('[data-animation-modal-title]');
+ var label = card.querySelector('[data-region-title]');
+ var config = getAnimationConfig(card);
+
+ if (title) {
+ title.textContent = (label ? label.textContent : 'Region') + ' animation settings';
+ }
+
+ ['intro', 'outro', 'loop'].forEach(function (stepName) {
+ var step = config[stepName] || normalizeAnimationStep({}, 'none');
+ var presetInput = getAnimationModalField(stepName, 'preset');
+ var durationInput = getAnimationModalField(stepName, 'duration_ms');
+ var delayInput = getAnimationModalField(stepName, 'delay_ms');
+
+ if (presetInput) { presetInput.value = step.preset; }
+ if (durationInput) { durationInput.value = step.duration_ms === null || step.duration_ms === undefined || Number(step.duration_ms) <= 0 ? '1000' : String(step.duration_ms); }
+ if (delayInput) { delayInput.value = step.delay_ms === null || step.delay_ms === undefined ? '0' : String(step.delay_ms); }
+ if (stepName === 'loop') {
+ var iterationsInput = getAnimationModalField(stepName, 'iterations');
+ if (iterationsInput) { iterationsInput.value = step.iterations === null || step.iterations === undefined ? '1' : String(step.iterations); }
+ }
+ });
+ }
+
+ function readNumberField(value) {
+ var raw = String(value || '').trim();
+ if (!raw) {
+ return null;
+ }
+ var next = Number(raw);
+ return Number.isFinite(next) ? Math.round(next) : null;
+ }
+
+ function readAnimationModalConfig() {
+ if (!animationAdvancedModal) {
+ return null;
+ }
+
+ var config = getAnimationConfig(activeAnimationCard) || { intro: {}, outro: {}, loop: {} };
+
+ ['intro', 'outro', 'loop'].forEach(function (stepName) {
+ var presetInput = getAnimationModalField(stepName, 'preset');
+ var durationInput = getAnimationModalField(stepName, 'duration_ms');
+ var delayInput = getAnimationModalField(stepName, 'delay_ms');
+
+ var nextStep = Object.assign({}, config[stepName] || {});
+ nextStep.preset = presetInput ? presetInput.value : nextStep.preset;
+ nextStep.duration_ms = readNumberField(durationInput ? durationInput.value : '') || 1000;
+ nextStep.delay_ms = readNumberField(delayInput ? delayInput.value : '') || 0;
+ if (stepName === 'loop') {
+ var iterationsInput = getAnimationModalField(stepName, 'iterations');
+ nextStep.iterations = readNumberField(iterationsInput ? iterationsInput.value : '') || 1;
+ }
+
+ config[stepName] = normalizeAnimationStep(nextStep, 'none');
+ });
+
+ return config;
+ }
+
+ function saveAnimationModal() {
+ if (!activeAnimationCard) {
+ return;
+ }
+
+ var config = readAnimationModalConfig();
+ if (!config) {
+ return;
+ }
+
+ writeAnimationConfig(activeAnimationCard, config);
+ renderOverlay();
+ }
+
+ function setPreviewControlState(active) {
+ updatePreviewControlState();
+ }
+
+ function updatePreviewCardVisibility() {
+ if (!previewCard) {
+ return;
+ }
+
+ previewCard.hidden = !hasAnyAnimationCards();
+ }
+
+ function setTemplateEditorLocked(locked) {
+ if (templateForm) {
+ templateForm.classList.toggle('template-designer-form--previewing', locked);
+ }
+ if (stage) {
+ stage.classList.toggle('is-previewing', locked);
+ }
+ if (previewCard) {
+ previewCard.classList.toggle('is-previewing', locked);
+ }
+ if (overlay) {
+ overlay.style.pointerEvents = locked ? 'none' : '';
+ }
+
+ var controls = templateForm ? templateForm.querySelectorAll('input, select, textarea, button') : [];
+ Array.prototype.forEach.call(controls, function (control) {
+ if (!control) {
+ return;
+ }
+ if (control.matches && control.matches('[data-preview-control]')) {
+ return;
+ }
+
+ if (locked) {
+ if (!control.hasAttribute('data-preview-disabled')) {
+ control.setAttribute('data-preview-disabled', control.disabled ? '1' : '0');
+ }
+ control.disabled = true;
+ } else if (control.hasAttribute('data-preview-disabled')) {
+ control.disabled = control.getAttribute('data-preview-disabled') === '1';
+ control.removeAttribute('data-preview-disabled');
+ }
+ });
+
+ setPreviewControlState(locked);
+ }
+
+ function stopTemplatePreview(skipRender) {
+ if (previewState.stopTimer) {
+ window.clearTimeout(previewState.stopTimer);
+ previewState.stopTimer = 0;
+ }
+
+ previewState.token += 1;
+ previewState.active = false;
+ previewState.mode = '';
+ clearPreviewAnimations();
+ setTemplateEditorLocked(false);
+
+ if (!skipRender) {
+ renderOverlay();
+ }
+ }
+
+ function schedulePreviewStop(delayMs, token) {
+ if (previewState.stopTimer) {
+ window.clearTimeout(previewState.stopTimer);
+ }
+
+ previewState.stopTimer = window.setTimeout(function () {
+ if (token !== previewState.token) {
+ return;
+ }
+ stopTemplatePreview(false);
+ }, Math.max(0, Number(delayMs || 0)));
+ }
+
+ function animatePreviewRects(mode, regions) {
+ var rects = getPreviewRects();
+ var token = previewState.token;
+ var stagger = 90;
+ var duration = 900;
+ var overlayRect = getOverlayRect();
+ var canvasMovement = Math.max(12, Math.round(overlayRect.height * 0.02));
+ var longestTimingMs = 0;
+
+ previewState.animations = [];
+
+ rects.forEach(function (rect, index) {
+ var region = regions && regions[index] ? regions[index] : null;
+ var step = getPreviewRegionAnimationStep(region, mode);
+ var preset = normalizePreviewAnimation(step && step.preset);
+ var delay = index * stagger;
+ var animation = null;
+ var animateCssMode = isAnimateCssPreset(preset);
+ var timingMs = delay + getPreviewAnimationStepTimingMs(step, mode, animateCssMode);
+
+ rect.style.willChange = 'transform, opacity, filter';
+
+ if (timingMs > longestTimingMs) {
+ longestTimingMs = timingMs;
+ }
+
+ if (preset === 'none') {
+ return;
+ }
+
+ if (animateCssMode) {
+ applyAnimateCssPreview(rect, preset, step, mode);
+ return;
+ }
+ if (mode === 'intro') {
+ animation = rect.animate([
+ { opacity: 0, transform: 'translate3d(0, ' + canvasMovement + 'px, 0) scale(0.96)', filter: 'blur(1px)' },
+ { opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)', filter: 'blur(0)' }
+ ], {
+ duration: duration,
+ delay: delay,
+ easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
+ fill: 'both'
+ });
+ } else if (mode === 'out') {
+ animation = rect.animate([
+ { opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)', filter: 'blur(0)' },
+ { opacity: 0, transform: 'translate3d(0, -' + canvasMovement + 'px, 0) scale(0.96)', filter: 'blur(1px)' }
+ ], {
+ duration: duration,
+ delay: delay,
+ easing: 'cubic-bezier(0.4, 0, 1, 1)',
+ fill: 'both'
+ });
+ }
+
+ if (animation) {
+ previewState.animations.push(animation);
+ }
+ });
+
+ schedulePreviewStop(longestTimingMs + 140, token);
+ }
+
+ function startTemplatePreview(mode) {
+ var previewMode = String(mode || '').trim().toLowerCase();
+ if (!previewMode) {
+ return;
+ }
+
+ var previewRegions = getCards().map(readCard);
+ stopTemplatePreview(true);
+ previewState.active = true;
+ previewState.mode = previewMode;
+ previewState.token += 1;
+ setTemplateEditorLocked(true);
+
+ if (!getPreviewRects().length) {
+ schedulePreviewStop(120, previewState.token);
+ return;
+ }
+ animatePreviewRects(previewMode, previewRegions);
+ }
+
function writeCard(card, values) {
if (utils.writeCard) {
utils.writeCard(card, values);
@@ -240,6 +835,7 @@
}
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
+ if (values.animation_json !== undefined) { writeAnimationConfig(card, values.animation_json); }
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
@@ -401,6 +997,7 @@
if (regionLockRatioInput) {
regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio);
}
+ writeAnimationConfig(card, region.animation_json);
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
@@ -452,6 +1049,9 @@
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
var widthInput = card.querySelector('[name="region_width[]"]');
var heightInput = card.querySelector('[name="region_height[]"]');
+ var animationPresetInputs = card.querySelectorAll('[data-animation-preset]');
+ var animationResetButtons = card.querySelectorAll('[data-animation-reset-button]');
+ var animationAdvancedButton = card.querySelector('[data-animation-advanced-button]');
nameInput.addEventListener('input', function () {
syncRegionIdentity(card, nameInput.value);
updateRegionLabel(card);
@@ -459,6 +1059,28 @@
renderRegionSidebar();
renderOverlay();
});
+ Array.prototype.forEach.call(animationPresetInputs, function (input) {
+ input.addEventListener('change', function () {
+ setAnimationPreset(card, input.getAttribute('data-animation-preset'), input.value);
+ requestOverlayRender();
+ });
+ });
+ Array.prototype.forEach.call(animationResetButtons, function (button) {
+ button.addEventListener('click', function (event) {
+ event.preventDefault();
+ setAnimationPreset(card, button.getAttribute('data-animation-reset-button'), 'none');
+ requestOverlayRender();
+ });
+ });
+ if (animationAdvancedButton && animationAdvancedModal) {
+ animationAdvancedButton.addEventListener('click', function () {
+ activeAnimationCard = card;
+ populateAnimationModal(card);
+ if (window.pulseModal) {
+ window.pulseModal.show(animationAdvancedModal);
+ }
+ });
+ }
if (lockRatioInput) {
lockRatioInput.addEventListener('input', function () {
updateRegionLockBadge(card);
@@ -536,28 +1158,49 @@
regionSelect.value = String(selectedIndex);
updateCanvasSizeLock();
validateRegionNames();
+ updatePreviewCardVisibility();
}
function renderOverlay() {
+ if (previewState.active) {
+ return;
+ }
+
var cards = getCards();
var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null;
var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1;
var regions = cards.map(readCard);
+ var canvasSize = getCanvasSize();
+ var overlayRect = getOverlayRect();
+ var overlayScale = overlayRect.width / Math.max(1, canvasSize.width);
+ overlay.style.setProperty('--designer-overlay-scale', String(overlayScale));
regionsJsonInput.value = JSON.stringify(regions);
overlay.innerHTML = regions.map(function (region, index) {
var box = canvasRectToPixels(region);
+ var renderedBox = {
+ left: Math.round(box.left),
+ top: Math.round(box.top),
+ width: Math.round(box.width),
+ height: Math.round(box.height)
+ };
var selected = index === selectedNow ? ' selected' : '';
- return '' + escapeHtml(region.label || region.region_key || 'Region') + '
';
+ return '' + escapeHtml(region.label || region.region_key || 'Region') + '
';
}).join('');
if (draft) {
- var rect = getOverlayRect();
- var size = getCanvasSize();
+ var rect = overlayRect;
+ var size = canvasSize;
var draftBox = { x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: Math.abs(draft.end.x - draft.start.x), height: Math.abs(draft.end.y - draft.start.y) };
- overlay.innerHTML += '
';
+ overlay.innerHTML += '
';
}
+
+ updatePreviewControlState();
}
function requestOverlayRender() {
+ if (previewState.active) {
+ return;
+ }
+
if (overlayRenderFrame) {
return;
}
@@ -575,6 +1218,10 @@
}
function setSelected(index) {
+ if (previewState.active) {
+ return;
+ }
+
var cards = getCards();
if (!cards.length) {
selectedIndex = -1;
@@ -588,6 +1235,10 @@
}
function addRegion(region) {
+ if (previewState.active) {
+ return;
+ }
+
var hint = regionList.querySelector('.muted');
if (hint) {
hint.remove();
@@ -613,6 +1264,7 @@
region_key: name,
label: name,
region_type: type,
+ animation_json: normalizeAnimationConfig('{}'),
x: 80,
y: 80,
width: size.width,
@@ -639,6 +1291,10 @@
}
function startDraw(event) {
+ if (previewState.active) {
+ return;
+ }
+
var start = toCanvasPoint(event);
draft = { start: start, end: start };
renderOverlay();
@@ -664,6 +1320,10 @@
}
function startMove(index, event) {
+ if (previewState.active) {
+ return;
+ }
+
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
function moveHandler(moveEvent) {
@@ -687,6 +1347,10 @@
}
function resizeFromHandle(index, dir, event) {
+ if (previewState.active) {
+ return;
+ }
+
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
@@ -824,6 +1488,31 @@
backgroundEmpty.style.display = 'block';
});
}
+ if (animationAdvancedModal) {
+ animationAdvancedModal.addEventListener('hidden.bs.modal', function () {
+ activeAnimationCard = null;
+ });
+ var animationModalApplyButton = animationAdvancedModal.querySelector('[data-animation-modal-apply]');
+ var animationModalResetButton = animationAdvancedModal.querySelector('[data-animation-modal-reset]');
+ if (animationModalApplyButton) {
+ animationModalApplyButton.addEventListener('click', function () {
+ saveAnimationModal();
+ if (window.pulseModal) {
+ window.pulseModal.hide(animationAdvancedModal);
+ }
+ });
+ }
+ if (animationModalResetButton) {
+ animationModalResetButton.addEventListener('click', function () {
+ if (!activeAnimationCard) {
+ return;
+ }
+ writeAnimationConfig(activeAnimationCard, normalizeAnimationConfig('{}'));
+ populateAnimationModal(activeAnimationCard);
+ requestOverlayRender();
+ });
+ }
+ }
if (backgroundColorInput) {
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
}
@@ -853,6 +1542,11 @@
});
if (templateForm) {
templateForm.addEventListener('formdata', function (event) {
+ if (previewState.active) {
+ event.preventDefault();
+ return;
+ }
+
if (!validateRegionNames()) {
event.preventDefault();
return;
@@ -868,6 +1562,10 @@
});
templateForm.addEventListener('submit', function () {
+ if (previewState.active) {
+ return;
+ }
+
if (!validateRegionNames()) {
return;
}
@@ -882,6 +1580,36 @@
});
}
+ if (previewIntroButton) {
+ previewIntroButton.addEventListener('click', function () {
+ if (previewState.active && previewState.mode === 'intro') {
+ stopTemplatePreview(false);
+ return;
+ }
+ startTemplatePreview('intro');
+ });
+ }
+
+ if (previewOutButton) {
+ previewOutButton.addEventListener('click', function () {
+ if (previewState.active && previewState.mode === 'out') {
+ stopTemplatePreview(false);
+ return;
+ }
+ startTemplatePreview('out');
+ });
+ }
+
+ if (previewContinuousButton) {
+ previewContinuousButton.addEventListener('click', function () {
+ if (previewState.active && previewState.mode === 'continuous') {
+ stopTemplatePreview(false);
+ return;
+ }
+ startTemplatePreview('continuous');
+ });
+ }
+
renderRegionList(existingRegions);
syncCanvasSizeSelection();
updateStageBackgroundColor();
diff --git a/src/web/public/vendor/animate.css/animate.min.css b/src/web/public/vendor/animate.css/animate.min.css
new file mode 100644
index 0000000..76d2fe1
--- /dev/null
+++ b/src/web/public/vendor/animate.css/animate.min.css
@@ -0,0 +1,7 @@
+@charset "UTF-8";/*!
+ * animate.css - https://animate.style/
+ * Version - 4.1.1
+ * Licensed under the MIT license - http://opensource.org/licenses/MIT
+ *
+ * Copyright (c) 2020 Animate.css
+ */:root{--animate-duration:1s;--animate-delay:1s;--animate-repeat:1}.animate__animated{-webkit-animation-duration:1s;animation-duration:1s;-webkit-animation-duration:var(--animate-duration);animation-duration:var(--animate-duration);-webkit-animation-fill-mode:both;animation-fill-mode:both}.animate__animated.animate__infinite{-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite}.animate__animated.animate__repeat-1{-webkit-animation-iteration-count:1;animation-iteration-count:1;-webkit-animation-iteration-count:var(--animate-repeat);animation-iteration-count:var(--animate-repeat)}.animate__animated.animate__repeat-2{-webkit-animation-iteration-count:2;animation-iteration-count:2;-webkit-animation-iteration-count:calc(var(--animate-repeat)*2);animation-iteration-count:calc(var(--animate-repeat)*2)}.animate__animated.animate__repeat-3{-webkit-animation-iteration-count:3;animation-iteration-count:3;-webkit-animation-iteration-count:calc(var(--animate-repeat)*3);animation-iteration-count:calc(var(--animate-repeat)*3)}.animate__animated.animate__delay-1s{-webkit-animation-delay:1s;animation-delay:1s;-webkit-animation-delay:var(--animate-delay);animation-delay:var(--animate-delay)}.animate__animated.animate__delay-2s{-webkit-animation-delay:2s;animation-delay:2s;-webkit-animation-delay:calc(var(--animate-delay)*2);animation-delay:calc(var(--animate-delay)*2)}.animate__animated.animate__delay-3s{-webkit-animation-delay:3s;animation-delay:3s;-webkit-animation-delay:calc(var(--animate-delay)*3);animation-delay:calc(var(--animate-delay)*3)}.animate__animated.animate__delay-4s{-webkit-animation-delay:4s;animation-delay:4s;-webkit-animation-delay:calc(var(--animate-delay)*4);animation-delay:calc(var(--animate-delay)*4)}.animate__animated.animate__delay-5s{-webkit-animation-delay:5s;animation-delay:5s;-webkit-animation-delay:calc(var(--animate-delay)*5);animation-delay:calc(var(--animate-delay)*5)}.animate__animated.animate__faster{-webkit-animation-duration:.5s;animation-duration:.5s;-webkit-animation-duration:calc(var(--animate-duration)/2);animation-duration:calc(var(--animate-duration)/2)}.animate__animated.animate__fast{-webkit-animation-duration:.8s;animation-duration:.8s;-webkit-animation-duration:calc(var(--animate-duration)*0.8);animation-duration:calc(var(--animate-duration)*0.8)}.animate__animated.animate__slow{-webkit-animation-duration:2s;animation-duration:2s;-webkit-animation-duration:calc(var(--animate-duration)*2);animation-duration:calc(var(--animate-duration)*2)}.animate__animated.animate__slower{-webkit-animation-duration:3s;animation-duration:3s;-webkit-animation-duration:calc(var(--animate-duration)*3);animation-duration:calc(var(--animate-duration)*3)}@media (prefers-reduced-motion:reduce),print{.animate__animated{-webkit-animation-duration:1ms!important;animation-duration:1ms!important;-webkit-transition-duration:1ms!important;transition-duration:1ms!important;-webkit-animation-iteration-count:1!important;animation-iteration-count:1!important}.animate__animated[class*=Out]{opacity:0}}@-webkit-keyframes bounce{0%,20%,53%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0);transform:translateZ(0)}40%,43%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06);-webkit-transform:translate3d(0,-30px,0) scaleY(1.1);transform:translate3d(0,-30px,0) scaleY(1.1)}70%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06);-webkit-transform:translate3d(0,-15px,0) scaleY(1.05);transform:translate3d(0,-15px,0) scaleY(1.05)}80%{-webkit-transition-timing-function:cubic-bezier(.215,.61,.355,1);transition-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0) scaleY(.95);transform:translateZ(0) scaleY(.95)}90%{-webkit-transform:translate3d(0,-4px,0) scaleY(1.02);transform:translate3d(0,-4px,0) scaleY(1.02)}}@keyframes bounce{0%,20%,53%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0);transform:translateZ(0)}40%,43%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06);-webkit-transform:translate3d(0,-30px,0) scaleY(1.1);transform:translate3d(0,-30px,0) scaleY(1.1)}70%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06);-webkit-transform:translate3d(0,-15px,0) scaleY(1.05);transform:translate3d(0,-15px,0) scaleY(1.05)}80%{-webkit-transition-timing-function:cubic-bezier(.215,.61,.355,1);transition-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0) scaleY(.95);transform:translateZ(0) scaleY(.95)}90%{-webkit-transform:translate3d(0,-4px,0) scaleY(1.02);transform:translate3d(0,-4px,0) scaleY(1.02)}}.animate__bounce{-webkit-animation-name:bounce;animation-name:bounce;-webkit-transform-origin:center bottom;transform-origin:center bottom}@-webkit-keyframes flash{0%,50%,to{opacity:1}25%,75%{opacity:0}}@keyframes flash{0%,50%,to{opacity:1}25%,75%{opacity:0}}.animate__flash{-webkit-animation-name:flash;animation-name:flash}@-webkit-keyframes pulse{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}50%{-webkit-transform:scale3d(1.05,1.05,1.05);transform:scale3d(1.05,1.05,1.05)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes pulse{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}50%{-webkit-transform:scale3d(1.05,1.05,1.05);transform:scale3d(1.05,1.05,1.05)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.animate__pulse{-webkit-animation-name:pulse;animation-name:pulse;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}@-webkit-keyframes rubberBand{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}30%{-webkit-transform:scale3d(1.25,.75,1);transform:scale3d(1.25,.75,1)}40%{-webkit-transform:scale3d(.75,1.25,1);transform:scale3d(.75,1.25,1)}50%{-webkit-transform:scale3d(1.15,.85,1);transform:scale3d(1.15,.85,1)}65%{-webkit-transform:scale3d(.95,1.05,1);transform:scale3d(.95,1.05,1)}75%{-webkit-transform:scale3d(1.05,.95,1);transform:scale3d(1.05,.95,1)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes rubberBand{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}30%{-webkit-transform:scale3d(1.25,.75,1);transform:scale3d(1.25,.75,1)}40%{-webkit-transform:scale3d(.75,1.25,1);transform:scale3d(.75,1.25,1)}50%{-webkit-transform:scale3d(1.15,.85,1);transform:scale3d(1.15,.85,1)}65%{-webkit-transform:scale3d(.95,1.05,1);transform:scale3d(.95,1.05,1)}75%{-webkit-transform:scale3d(1.05,.95,1);transform:scale3d(1.05,.95,1)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.animate__rubberBand{-webkit-animation-name:rubberBand;animation-name:rubberBand}@-webkit-keyframes shakeX{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}20%,40%,60%,80%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}}@keyframes shakeX{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}20%,40%,60%,80%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}}.animate__shakeX{-webkit-animation-name:shakeX;animation-name:shakeX}@-webkit-keyframes shakeY{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}20%,40%,60%,80%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}}@keyframes shakeY{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}20%,40%,60%,80%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}}.animate__shakeY{-webkit-animation-name:shakeY;animation-name:shakeY}@-webkit-keyframes headShake{0%{-webkit-transform:translateX(0);transform:translateX(0)}6.5%{-webkit-transform:translateX(-6px) rotateY(-9deg);transform:translateX(-6px) rotateY(-9deg)}18.5%{-webkit-transform:translateX(5px) rotateY(7deg);transform:translateX(5px) rotateY(7deg)}31.5%{-webkit-transform:translateX(-3px) rotateY(-5deg);transform:translateX(-3px) rotateY(-5deg)}43.5%{-webkit-transform:translateX(2px) rotateY(3deg);transform:translateX(2px) rotateY(3deg)}50%{-webkit-transform:translateX(0);transform:translateX(0)}}@keyframes headShake{0%{-webkit-transform:translateX(0);transform:translateX(0)}6.5%{-webkit-transform:translateX(-6px) rotateY(-9deg);transform:translateX(-6px) rotateY(-9deg)}18.5%{-webkit-transform:translateX(5px) rotateY(7deg);transform:translateX(5px) rotateY(7deg)}31.5%{-webkit-transform:translateX(-3px) rotateY(-5deg);transform:translateX(-3px) rotateY(-5deg)}43.5%{-webkit-transform:translateX(2px) rotateY(3deg);transform:translateX(2px) rotateY(3deg)}50%{-webkit-transform:translateX(0);transform:translateX(0)}}.animate__headShake{-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;-webkit-animation-name:headShake;animation-name:headShake}@-webkit-keyframes swing{20%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}40%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}60%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}80%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes swing{20%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}40%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}60%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}80%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}.animate__swing{-webkit-transform-origin:top center;transform-origin:top center;-webkit-animation-name:swing;animation-name:swing}@-webkit-keyframes tada{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}10%,20%{-webkit-transform:scale3d(.9,.9,.9) rotate(-3deg);transform:scale3d(.9,.9,.9) rotate(-3deg)}30%,50%,70%,90%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(3deg);transform:scale3d(1.1,1.1,1.1) rotate(3deg)}40%,60%,80%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(-3deg);transform:scale3d(1.1,1.1,1.1) rotate(-3deg)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes tada{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}10%,20%{-webkit-transform:scale3d(.9,.9,.9) rotate(-3deg);transform:scale3d(.9,.9,.9) rotate(-3deg)}30%,50%,70%,90%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(3deg);transform:scale3d(1.1,1.1,1.1) rotate(3deg)}40%,60%,80%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(-3deg);transform:scale3d(1.1,1.1,1.1) rotate(-3deg)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.animate__tada{-webkit-animation-name:tada;animation-name:tada}@-webkit-keyframes wobble{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}15%{-webkit-transform:translate3d(-25%,0,0) rotate(-5deg);transform:translate3d(-25%,0,0) rotate(-5deg)}30%{-webkit-transform:translate3d(20%,0,0) rotate(3deg);transform:translate3d(20%,0,0) rotate(3deg)}45%{-webkit-transform:translate3d(-15%,0,0) rotate(-3deg);transform:translate3d(-15%,0,0) rotate(-3deg)}60%{-webkit-transform:translate3d(10%,0,0) rotate(2deg);transform:translate3d(10%,0,0) rotate(2deg)}75%{-webkit-transform:translate3d(-5%,0,0) rotate(-1deg);transform:translate3d(-5%,0,0) rotate(-1deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes wobble{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}15%{-webkit-transform:translate3d(-25%,0,0) rotate(-5deg);transform:translate3d(-25%,0,0) rotate(-5deg)}30%{-webkit-transform:translate3d(20%,0,0) rotate(3deg);transform:translate3d(20%,0,0) rotate(3deg)}45%{-webkit-transform:translate3d(-15%,0,0) rotate(-3deg);transform:translate3d(-15%,0,0) rotate(-3deg)}60%{-webkit-transform:translate3d(10%,0,0) rotate(2deg);transform:translate3d(10%,0,0) rotate(2deg)}75%{-webkit-transform:translate3d(-5%,0,0) rotate(-1deg);transform:translate3d(-5%,0,0) rotate(-1deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__wobble{-webkit-animation-name:wobble;animation-name:wobble}@-webkit-keyframes jello{0%,11.1%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}22.2%{-webkit-transform:skewX(-12.5deg) skewY(-12.5deg);transform:skewX(-12.5deg) skewY(-12.5deg)}33.3%{-webkit-transform:skewX(6.25deg) skewY(6.25deg);transform:skewX(6.25deg) skewY(6.25deg)}44.4%{-webkit-transform:skewX(-3.125deg) skewY(-3.125deg);transform:skewX(-3.125deg) skewY(-3.125deg)}55.5%{-webkit-transform:skewX(1.5625deg) skewY(1.5625deg);transform:skewX(1.5625deg) skewY(1.5625deg)}66.6%{-webkit-transform:skewX(-.78125deg) skewY(-.78125deg);transform:skewX(-.78125deg) skewY(-.78125deg)}77.7%{-webkit-transform:skewX(.390625deg) skewY(.390625deg);transform:skewX(.390625deg) skewY(.390625deg)}88.8%{-webkit-transform:skewX(-.1953125deg) skewY(-.1953125deg);transform:skewX(-.1953125deg) skewY(-.1953125deg)}}@keyframes jello{0%,11.1%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}22.2%{-webkit-transform:skewX(-12.5deg) skewY(-12.5deg);transform:skewX(-12.5deg) skewY(-12.5deg)}33.3%{-webkit-transform:skewX(6.25deg) skewY(6.25deg);transform:skewX(6.25deg) skewY(6.25deg)}44.4%{-webkit-transform:skewX(-3.125deg) skewY(-3.125deg);transform:skewX(-3.125deg) skewY(-3.125deg)}55.5%{-webkit-transform:skewX(1.5625deg) skewY(1.5625deg);transform:skewX(1.5625deg) skewY(1.5625deg)}66.6%{-webkit-transform:skewX(-.78125deg) skewY(-.78125deg);transform:skewX(-.78125deg) skewY(-.78125deg)}77.7%{-webkit-transform:skewX(.390625deg) skewY(.390625deg);transform:skewX(.390625deg) skewY(.390625deg)}88.8%{-webkit-transform:skewX(-.1953125deg) skewY(-.1953125deg);transform:skewX(-.1953125deg) skewY(-.1953125deg)}}.animate__jello{-webkit-animation-name:jello;animation-name:jello;-webkit-transform-origin:center;transform-origin:center}@-webkit-keyframes heartBeat{0%{-webkit-transform:scale(1);transform:scale(1)}14%{-webkit-transform:scale(1.3);transform:scale(1.3)}28%{-webkit-transform:scale(1);transform:scale(1)}42%{-webkit-transform:scale(1.3);transform:scale(1.3)}70%{-webkit-transform:scale(1);transform:scale(1)}}@keyframes heartBeat{0%{-webkit-transform:scale(1);transform:scale(1)}14%{-webkit-transform:scale(1.3);transform:scale(1.3)}28%{-webkit-transform:scale(1);transform:scale(1)}42%{-webkit-transform:scale(1.3);transform:scale(1.3)}70%{-webkit-transform:scale(1);transform:scale(1)}}.animate__heartBeat{-webkit-animation-name:heartBeat;animation-name:heartBeat;-webkit-animation-duration:1.3s;animation-duration:1.3s;-webkit-animation-duration:calc(var(--animate-duration)*1.3);animation-duration:calc(var(--animate-duration)*1.3);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}@-webkit-keyframes backInDown{0%{-webkit-transform:translateY(-1200px) scale(.7);transform:translateY(-1200px) scale(.7);opacity:.7}80%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}@keyframes backInDown{0%{-webkit-transform:translateY(-1200px) scale(.7);transform:translateY(-1200px) scale(.7);opacity:.7}80%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}.animate__backInDown{-webkit-animation-name:backInDown;animation-name:backInDown}@-webkit-keyframes backInLeft{0%{-webkit-transform:translateX(-2000px) scale(.7);transform:translateX(-2000px) scale(.7);opacity:.7}80%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}@keyframes backInLeft{0%{-webkit-transform:translateX(-2000px) scale(.7);transform:translateX(-2000px) scale(.7);opacity:.7}80%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}.animate__backInLeft{-webkit-animation-name:backInLeft;animation-name:backInLeft}@-webkit-keyframes backInRight{0%{-webkit-transform:translateX(2000px) scale(.7);transform:translateX(2000px) scale(.7);opacity:.7}80%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}@keyframes backInRight{0%{-webkit-transform:translateX(2000px) scale(.7);transform:translateX(2000px) scale(.7);opacity:.7}80%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}.animate__backInRight{-webkit-animation-name:backInRight;animation-name:backInRight}@-webkit-keyframes backInUp{0%{-webkit-transform:translateY(1200px) scale(.7);transform:translateY(1200px) scale(.7);opacity:.7}80%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}@keyframes backInUp{0%{-webkit-transform:translateY(1200px) scale(.7);transform:translateY(1200px) scale(.7);opacity:.7}80%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:scale(1);transform:scale(1);opacity:1}}.animate__backInUp{-webkit-animation-name:backInUp;animation-name:backInUp}@-webkit-keyframes backOutDown{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:translateY(700px) scale(.7);transform:translateY(700px) scale(.7);opacity:.7}}@keyframes backOutDown{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:translateY(700px) scale(.7);transform:translateY(700px) scale(.7);opacity:.7}}.animate__backOutDown{-webkit-animation-name:backOutDown;animation-name:backOutDown}@-webkit-keyframes backOutLeft{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:translateX(-2000px) scale(.7);transform:translateX(-2000px) scale(.7);opacity:.7}}@keyframes backOutLeft{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:translateX(-2000px) scale(.7);transform:translateX(-2000px) scale(.7);opacity:.7}}.animate__backOutLeft{-webkit-animation-name:backOutLeft;animation-name:backOutLeft}@-webkit-keyframes backOutRight{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:translateX(2000px) scale(.7);transform:translateX(2000px) scale(.7);opacity:.7}}@keyframes backOutRight{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateX(0) scale(.7);transform:translateX(0) scale(.7);opacity:.7}to{-webkit-transform:translateX(2000px) scale(.7);transform:translateX(2000px) scale(.7);opacity:.7}}.animate__backOutRight{-webkit-animation-name:backOutRight;animation-name:backOutRight}@-webkit-keyframes backOutUp{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:translateY(-700px) scale(.7);transform:translateY(-700px) scale(.7);opacity:.7}}@keyframes backOutUp{0%{-webkit-transform:scale(1);transform:scale(1);opacity:1}20%{-webkit-transform:translateY(0) scale(.7);transform:translateY(0) scale(.7);opacity:.7}to{-webkit-transform:translateY(-700px) scale(.7);transform:translateY(-700px) scale(.7);opacity:.7}}.animate__backOutUp{-webkit-animation-name:backOutUp;animation-name:backOutUp}@-webkit-keyframes bounceIn{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}20%{-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}40%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}60%{opacity:1;-webkit-transform:scale3d(1.03,1.03,1.03);transform:scale3d(1.03,1.03,1.03)}80%{-webkit-transform:scale3d(.97,.97,.97);transform:scale3d(.97,.97,.97)}to{opacity:1;-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes bounceIn{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}20%{-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}40%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}60%{opacity:1;-webkit-transform:scale3d(1.03,1.03,1.03);transform:scale3d(1.03,1.03,1.03)}80%{-webkit-transform:scale3d(.97,.97,.97);transform:scale3d(.97,.97,.97)}to{opacity:1;-webkit-transform:scaleX(1);transform:scaleX(1)}}.animate__bounceIn{-webkit-animation-duration:.75s;animation-duration:.75s;-webkit-animation-duration:calc(var(--animate-duration)*0.75);animation-duration:calc(var(--animate-duration)*0.75);-webkit-animation-name:bounceIn;animation-name:bounceIn}@-webkit-keyframes bounceInDown{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,-3000px,0) scaleY(3);transform:translate3d(0,-3000px,0) scaleY(3)}60%{opacity:1;-webkit-transform:translate3d(0,25px,0) scaleY(.9);transform:translate3d(0,25px,0) scaleY(.9)}75%{-webkit-transform:translate3d(0,-10px,0) scaleY(.95);transform:translate3d(0,-10px,0) scaleY(.95)}90%{-webkit-transform:translate3d(0,5px,0) scaleY(.985);transform:translate3d(0,5px,0) scaleY(.985)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInDown{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,-3000px,0) scaleY(3);transform:translate3d(0,-3000px,0) scaleY(3)}60%{opacity:1;-webkit-transform:translate3d(0,25px,0) scaleY(.9);transform:translate3d(0,25px,0) scaleY(.9)}75%{-webkit-transform:translate3d(0,-10px,0) scaleY(.95);transform:translate3d(0,-10px,0) scaleY(.95)}90%{-webkit-transform:translate3d(0,5px,0) scaleY(.985);transform:translate3d(0,5px,0) scaleY(.985)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__bounceInDown{-webkit-animation-name:bounceInDown;animation-name:bounceInDown}@-webkit-keyframes bounceInLeft{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(-3000px,0,0) scaleX(3);transform:translate3d(-3000px,0,0) scaleX(3)}60%{opacity:1;-webkit-transform:translate3d(25px,0,0) scaleX(1);transform:translate3d(25px,0,0) scaleX(1)}75%{-webkit-transform:translate3d(-10px,0,0) scaleX(.98);transform:translate3d(-10px,0,0) scaleX(.98)}90%{-webkit-transform:translate3d(5px,0,0) scaleX(.995);transform:translate3d(5px,0,0) scaleX(.995)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInLeft{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(-3000px,0,0) scaleX(3);transform:translate3d(-3000px,0,0) scaleX(3)}60%{opacity:1;-webkit-transform:translate3d(25px,0,0) scaleX(1);transform:translate3d(25px,0,0) scaleX(1)}75%{-webkit-transform:translate3d(-10px,0,0) scaleX(.98);transform:translate3d(-10px,0,0) scaleX(.98)}90%{-webkit-transform:translate3d(5px,0,0) scaleX(.995);transform:translate3d(5px,0,0) scaleX(.995)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__bounceInLeft{-webkit-animation-name:bounceInLeft;animation-name:bounceInLeft}@-webkit-keyframes bounceInRight{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(3000px,0,0) scaleX(3);transform:translate3d(3000px,0,0) scaleX(3)}60%{opacity:1;-webkit-transform:translate3d(-25px,0,0) scaleX(1);transform:translate3d(-25px,0,0) scaleX(1)}75%{-webkit-transform:translate3d(10px,0,0) scaleX(.98);transform:translate3d(10px,0,0) scaleX(.98)}90%{-webkit-transform:translate3d(-5px,0,0) scaleX(.995);transform:translate3d(-5px,0,0) scaleX(.995)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInRight{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(3000px,0,0) scaleX(3);transform:translate3d(3000px,0,0) scaleX(3)}60%{opacity:1;-webkit-transform:translate3d(-25px,0,0) scaleX(1);transform:translate3d(-25px,0,0) scaleX(1)}75%{-webkit-transform:translate3d(10px,0,0) scaleX(.98);transform:translate3d(10px,0,0) scaleX(.98)}90%{-webkit-transform:translate3d(-5px,0,0) scaleX(.995);transform:translate3d(-5px,0,0) scaleX(.995)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__bounceInRight{-webkit-animation-name:bounceInRight;animation-name:bounceInRight}@-webkit-keyframes bounceInUp{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,3000px,0) scaleY(5);transform:translate3d(0,3000px,0) scaleY(5)}60%{opacity:1;-webkit-transform:translate3d(0,-20px,0) scaleY(.9);transform:translate3d(0,-20px,0) scaleY(.9)}75%{-webkit-transform:translate3d(0,10px,0) scaleY(.95);transform:translate3d(0,10px,0) scaleY(.95)}90%{-webkit-transform:translate3d(0,-5px,0) scaleY(.985);transform:translate3d(0,-5px,0) scaleY(.985)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInUp{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,3000px,0) scaleY(5);transform:translate3d(0,3000px,0) scaleY(5)}60%{opacity:1;-webkit-transform:translate3d(0,-20px,0) scaleY(.9);transform:translate3d(0,-20px,0) scaleY(.9)}75%{-webkit-transform:translate3d(0,10px,0) scaleY(.95);transform:translate3d(0,10px,0) scaleY(.95)}90%{-webkit-transform:translate3d(0,-5px,0) scaleY(.985);transform:translate3d(0,-5px,0) scaleY(.985)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__bounceInUp{-webkit-animation-name:bounceInUp;animation-name:bounceInUp}@-webkit-keyframes bounceOut{20%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}50%,55%{opacity:1;-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}to{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}}@keyframes bounceOut{20%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}50%,55%{opacity:1;-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}to{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}}.animate__bounceOut{-webkit-animation-duration:.75s;animation-duration:.75s;-webkit-animation-duration:calc(var(--animate-duration)*0.75);animation-duration:calc(var(--animate-duration)*0.75);-webkit-animation-name:bounceOut;animation-name:bounceOut}@-webkit-keyframes bounceOutDown{20%{-webkit-transform:translate3d(0,10px,0) scaleY(.985);transform:translate3d(0,10px,0) scaleY(.985)}40%,45%{opacity:1;-webkit-transform:translate3d(0,-20px,0) scaleY(.9);transform:translate3d(0,-20px,0) scaleY(.9)}to{opacity:0;-webkit-transform:translate3d(0,2000px,0) scaleY(3);transform:translate3d(0,2000px,0) scaleY(3)}}@keyframes bounceOutDown{20%{-webkit-transform:translate3d(0,10px,0) scaleY(.985);transform:translate3d(0,10px,0) scaleY(.985)}40%,45%{opacity:1;-webkit-transform:translate3d(0,-20px,0) scaleY(.9);transform:translate3d(0,-20px,0) scaleY(.9)}to{opacity:0;-webkit-transform:translate3d(0,2000px,0) scaleY(3);transform:translate3d(0,2000px,0) scaleY(3)}}.animate__bounceOutDown{-webkit-animation-name:bounceOutDown;animation-name:bounceOutDown}@-webkit-keyframes bounceOutLeft{20%{opacity:1;-webkit-transform:translate3d(20px,0,0) scaleX(.9);transform:translate3d(20px,0,0) scaleX(.9)}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0) scaleX(2);transform:translate3d(-2000px,0,0) scaleX(2)}}@keyframes bounceOutLeft{20%{opacity:1;-webkit-transform:translate3d(20px,0,0) scaleX(.9);transform:translate3d(20px,0,0) scaleX(.9)}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0) scaleX(2);transform:translate3d(-2000px,0,0) scaleX(2)}}.animate__bounceOutLeft{-webkit-animation-name:bounceOutLeft;animation-name:bounceOutLeft}@-webkit-keyframes bounceOutRight{20%{opacity:1;-webkit-transform:translate3d(-20px,0,0) scaleX(.9);transform:translate3d(-20px,0,0) scaleX(.9)}to{opacity:0;-webkit-transform:translate3d(2000px,0,0) scaleX(2);transform:translate3d(2000px,0,0) scaleX(2)}}@keyframes bounceOutRight{20%{opacity:1;-webkit-transform:translate3d(-20px,0,0) scaleX(.9);transform:translate3d(-20px,0,0) scaleX(.9)}to{opacity:0;-webkit-transform:translate3d(2000px,0,0) scaleX(2);transform:translate3d(2000px,0,0) scaleX(2)}}.animate__bounceOutRight{-webkit-animation-name:bounceOutRight;animation-name:bounceOutRight}@-webkit-keyframes bounceOutUp{20%{-webkit-transform:translate3d(0,-10px,0) scaleY(.985);transform:translate3d(0,-10px,0) scaleY(.985)}40%,45%{opacity:1;-webkit-transform:translate3d(0,20px,0) scaleY(.9);transform:translate3d(0,20px,0) scaleY(.9)}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0) scaleY(3);transform:translate3d(0,-2000px,0) scaleY(3)}}@keyframes bounceOutUp{20%{-webkit-transform:translate3d(0,-10px,0) scaleY(.985);transform:translate3d(0,-10px,0) scaleY(.985)}40%,45%{opacity:1;-webkit-transform:translate3d(0,20px,0) scaleY(.9);transform:translate3d(0,20px,0) scaleY(.9)}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0) scaleY(3);transform:translate3d(0,-2000px,0) scaleY(3)}}.animate__bounceOutUp{-webkit-animation-name:bounceOutUp;animation-name:bounceOutUp}@-webkit-keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}.animate__fadeIn{-webkit-animation-name:fadeIn;animation-name:fadeIn}@-webkit-keyframes fadeInDown{0%{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInDown{0%{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInDown{-webkit-animation-name:fadeInDown;animation-name:fadeInDown}@-webkit-keyframes fadeInDownBig{0%{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInDownBig{0%{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInDownBig{-webkit-animation-name:fadeInDownBig;animation-name:fadeInDownBig}@-webkit-keyframes fadeInLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInLeft{-webkit-animation-name:fadeInLeft;animation-name:fadeInLeft}@-webkit-keyframes fadeInLeftBig{0%{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInLeftBig{0%{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInLeftBig{-webkit-animation-name:fadeInLeftBig;animation-name:fadeInLeftBig}@-webkit-keyframes fadeInRight{0%{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInRight{0%{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInRight{-webkit-animation-name:fadeInRight;animation-name:fadeInRight}@-webkit-keyframes fadeInRightBig{0%{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInRightBig{0%{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInRightBig{-webkit-animation-name:fadeInRightBig;animation-name:fadeInRightBig}@-webkit-keyframes fadeInUp{0%{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInUp{0%{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInUp{-webkit-animation-name:fadeInUp;animation-name:fadeInUp}@-webkit-keyframes fadeInUpBig{0%{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInUpBig{0%{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInUpBig{-webkit-animation-name:fadeInUpBig;animation-name:fadeInUpBig}@-webkit-keyframes fadeInTopLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,-100%,0);transform:translate3d(-100%,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInTopLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,-100%,0);transform:translate3d(-100%,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInTopLeft{-webkit-animation-name:fadeInTopLeft;animation-name:fadeInTopLeft}@-webkit-keyframes fadeInTopRight{0%{opacity:0;-webkit-transform:translate3d(100%,-100%,0);transform:translate3d(100%,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInTopRight{0%{opacity:0;-webkit-transform:translate3d(100%,-100%,0);transform:translate3d(100%,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInTopRight{-webkit-animation-name:fadeInTopRight;animation-name:fadeInTopRight}@-webkit-keyframes fadeInBottomLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,100%,0);transform:translate3d(-100%,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInBottomLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,100%,0);transform:translate3d(-100%,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInBottomLeft{-webkit-animation-name:fadeInBottomLeft;animation-name:fadeInBottomLeft}@-webkit-keyframes fadeInBottomRight{0%{opacity:0;-webkit-transform:translate3d(100%,100%,0);transform:translate3d(100%,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInBottomRight{0%{opacity:0;-webkit-transform:translate3d(100%,100%,0);transform:translate3d(100%,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__fadeInBottomRight{-webkit-animation-name:fadeInBottomRight;animation-name:fadeInBottomRight}@-webkit-keyframes fadeOut{0%{opacity:1}to{opacity:0}}@keyframes fadeOut{0%{opacity:1}to{opacity:0}}.animate__fadeOut{-webkit-animation-name:fadeOut;animation-name:fadeOut}@-webkit-keyframes fadeOutDown{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}@keyframes fadeOutDown{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}.animate__fadeOutDown{-webkit-animation-name:fadeOutDown;animation-name:fadeOutDown}@-webkit-keyframes fadeOutDownBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}@keyframes fadeOutDownBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}.animate__fadeOutDownBig{-webkit-animation-name:fadeOutDownBig;animation-name:fadeOutDownBig}@-webkit-keyframes fadeOutLeft{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}@keyframes fadeOutLeft{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}.animate__fadeOutLeft{-webkit-animation-name:fadeOutLeft;animation-name:fadeOutLeft}@-webkit-keyframes fadeOutLeftBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}@keyframes fadeOutLeftBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}.animate__fadeOutLeftBig{-webkit-animation-name:fadeOutLeftBig;animation-name:fadeOutLeftBig}@-webkit-keyframes fadeOutRight{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}@keyframes fadeOutRight{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}.animate__fadeOutRight{-webkit-animation-name:fadeOutRight;animation-name:fadeOutRight}@-webkit-keyframes fadeOutRightBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}@keyframes fadeOutRightBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}.animate__fadeOutRightBig{-webkit-animation-name:fadeOutRightBig;animation-name:fadeOutRightBig}@-webkit-keyframes fadeOutUp{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}@keyframes fadeOutUp{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}.animate__fadeOutUp{-webkit-animation-name:fadeOutUp;animation-name:fadeOutUp}@-webkit-keyframes fadeOutUpBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}@keyframes fadeOutUpBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}.animate__fadeOutUpBig{-webkit-animation-name:fadeOutUpBig;animation-name:fadeOutUpBig}@-webkit-keyframes fadeOutTopLeft{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(-100%,-100%,0);transform:translate3d(-100%,-100%,0)}}@keyframes fadeOutTopLeft{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(-100%,-100%,0);transform:translate3d(-100%,-100%,0)}}.animate__fadeOutTopLeft{-webkit-animation-name:fadeOutTopLeft;animation-name:fadeOutTopLeft}@-webkit-keyframes fadeOutTopRight{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(100%,-100%,0);transform:translate3d(100%,-100%,0)}}@keyframes fadeOutTopRight{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(100%,-100%,0);transform:translate3d(100%,-100%,0)}}.animate__fadeOutTopRight{-webkit-animation-name:fadeOutTopRight;animation-name:fadeOutTopRight}@-webkit-keyframes fadeOutBottomRight{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(100%,100%,0);transform:translate3d(100%,100%,0)}}@keyframes fadeOutBottomRight{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(100%,100%,0);transform:translate3d(100%,100%,0)}}.animate__fadeOutBottomRight{-webkit-animation-name:fadeOutBottomRight;animation-name:fadeOutBottomRight}@-webkit-keyframes fadeOutBottomLeft{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(-100%,100%,0);transform:translate3d(-100%,100%,0)}}@keyframes fadeOutBottomLeft{0%{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}to{opacity:0;-webkit-transform:translate3d(-100%,100%,0);transform:translate3d(-100%,100%,0)}}.animate__fadeOutBottomLeft{-webkit-animation-name:fadeOutBottomLeft;animation-name:fadeOutBottomLeft}@-webkit-keyframes flip{0%{-webkit-transform:perspective(400px) scaleX(1) translateZ(0) rotateY(-1turn);transform:perspective(400px) scaleX(1) translateZ(0) rotateY(-1turn);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}40%{-webkit-transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-190deg);transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-190deg);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}50%{-webkit-transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-170deg);transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-170deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}80%{-webkit-transform:perspective(400px) scale3d(.95,.95,.95) translateZ(0) rotateY(0deg);transform:perspective(400px) scale3d(.95,.95,.95) translateZ(0) rotateY(0deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}to{-webkit-transform:perspective(400px) scaleX(1) translateZ(0) rotateY(0deg);transform:perspective(400px) scaleX(1) translateZ(0) rotateY(0deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}}@keyframes flip{0%{-webkit-transform:perspective(400px) scaleX(1) translateZ(0) rotateY(-1turn);transform:perspective(400px) scaleX(1) translateZ(0) rotateY(-1turn);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}40%{-webkit-transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-190deg);transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-190deg);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}50%{-webkit-transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-170deg);transform:perspective(400px) scaleX(1) translateZ(150px) rotateY(-170deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}80%{-webkit-transform:perspective(400px) scale3d(.95,.95,.95) translateZ(0) rotateY(0deg);transform:perspective(400px) scale3d(.95,.95,.95) translateZ(0) rotateY(0deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}to{-webkit-transform:perspective(400px) scaleX(1) translateZ(0) rotateY(0deg);transform:perspective(400px) scaleX(1) translateZ(0) rotateY(0deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}}.animate__animated.animate__flip{-webkit-backface-visibility:visible;backface-visibility:visible;-webkit-animation-name:flip;animation-name:flip}@-webkit-keyframes flipInX{0%{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in;opacity:0}40%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}60%{-webkit-transform:perspective(400px) rotateX(10deg);transform:perspective(400px) rotateX(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateX(-5deg);transform:perspective(400px) rotateX(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}@keyframes flipInX{0%{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in;opacity:0}40%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}60%{-webkit-transform:perspective(400px) rotateX(10deg);transform:perspective(400px) rotateX(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateX(-5deg);transform:perspective(400px) rotateX(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}.animate__flipInX{-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipInX;animation-name:flipInX}@-webkit-keyframes flipInY{0%{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in;opacity:0}40%{-webkit-transform:perspective(400px) rotateY(-20deg);transform:perspective(400px) rotateY(-20deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}60%{-webkit-transform:perspective(400px) rotateY(10deg);transform:perspective(400px) rotateY(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateY(-5deg);transform:perspective(400px) rotateY(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}@keyframes flipInY{0%{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in;opacity:0}40%{-webkit-transform:perspective(400px) rotateY(-20deg);transform:perspective(400px) rotateY(-20deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}60%{-webkit-transform:perspective(400px) rotateY(10deg);transform:perspective(400px) rotateY(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateY(-5deg);transform:perspective(400px) rotateY(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}.animate__flipInY{-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipInY;animation-name:flipInY}@-webkit-keyframes flipOutX{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);opacity:1}to{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}}@keyframes flipOutX{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);opacity:1}to{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}}.animate__flipOutX{-webkit-animation-duration:.75s;animation-duration:.75s;-webkit-animation-duration:calc(var(--animate-duration)*0.75);animation-duration:calc(var(--animate-duration)*0.75);-webkit-animation-name:flipOutX;animation-name:flipOutX;-webkit-backface-visibility:visible!important;backface-visibility:visible!important}@-webkit-keyframes flipOutY{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateY(-15deg);transform:perspective(400px) rotateY(-15deg);opacity:1}to{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}}@keyframes flipOutY{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateY(-15deg);transform:perspective(400px) rotateY(-15deg);opacity:1}to{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}}.animate__flipOutY{-webkit-animation-duration:.75s;animation-duration:.75s;-webkit-animation-duration:calc(var(--animate-duration)*0.75);animation-duration:calc(var(--animate-duration)*0.75);-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipOutY;animation-name:flipOutY}@-webkit-keyframes lightSpeedInRight{0%{-webkit-transform:translate3d(100%,0,0) skewX(-30deg);transform:translate3d(100%,0,0) skewX(-30deg);opacity:0}60%{-webkit-transform:skewX(20deg);transform:skewX(20deg);opacity:1}80%{-webkit-transform:skewX(-5deg);transform:skewX(-5deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes lightSpeedInRight{0%{-webkit-transform:translate3d(100%,0,0) skewX(-30deg);transform:translate3d(100%,0,0) skewX(-30deg);opacity:0}60%{-webkit-transform:skewX(20deg);transform:skewX(20deg);opacity:1}80%{-webkit-transform:skewX(-5deg);transform:skewX(-5deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__lightSpeedInRight{-webkit-animation-name:lightSpeedInRight;animation-name:lightSpeedInRight;-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}@-webkit-keyframes lightSpeedInLeft{0%{-webkit-transform:translate3d(-100%,0,0) skewX(30deg);transform:translate3d(-100%,0,0) skewX(30deg);opacity:0}60%{-webkit-transform:skewX(-20deg);transform:skewX(-20deg);opacity:1}80%{-webkit-transform:skewX(5deg);transform:skewX(5deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes lightSpeedInLeft{0%{-webkit-transform:translate3d(-100%,0,0) skewX(30deg);transform:translate3d(-100%,0,0) skewX(30deg);opacity:0}60%{-webkit-transform:skewX(-20deg);transform:skewX(-20deg);opacity:1}80%{-webkit-transform:skewX(5deg);transform:skewX(5deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__lightSpeedInLeft{-webkit-animation-name:lightSpeedInLeft;animation-name:lightSpeedInLeft;-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}@-webkit-keyframes lightSpeedOutRight{0%{opacity:1}to{-webkit-transform:translate3d(100%,0,0) skewX(30deg);transform:translate3d(100%,0,0) skewX(30deg);opacity:0}}@keyframes lightSpeedOutRight{0%{opacity:1}to{-webkit-transform:translate3d(100%,0,0) skewX(30deg);transform:translate3d(100%,0,0) skewX(30deg);opacity:0}}.animate__lightSpeedOutRight{-webkit-animation-name:lightSpeedOutRight;animation-name:lightSpeedOutRight;-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}@-webkit-keyframes lightSpeedOutLeft{0%{opacity:1}to{-webkit-transform:translate3d(-100%,0,0) skewX(-30deg);transform:translate3d(-100%,0,0) skewX(-30deg);opacity:0}}@keyframes lightSpeedOutLeft{0%{opacity:1}to{-webkit-transform:translate3d(-100%,0,0) skewX(-30deg);transform:translate3d(-100%,0,0) skewX(-30deg);opacity:0}}.animate__lightSpeedOutLeft{-webkit-animation-name:lightSpeedOutLeft;animation-name:lightSpeedOutLeft;-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}@-webkit-keyframes rotateIn{0%{-webkit-transform:rotate(-200deg);transform:rotate(-200deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateIn{0%{-webkit-transform:rotate(-200deg);transform:rotate(-200deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.animate__rotateIn{-webkit-animation-name:rotateIn;animation-name:rotateIn;-webkit-transform-origin:center;transform-origin:center}@-webkit-keyframes rotateInDownLeft{0%{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateInDownLeft{0%{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.animate__rotateInDownLeft{-webkit-animation-name:rotateInDownLeft;animation-name:rotateInDownLeft;-webkit-transform-origin:left bottom;transform-origin:left bottom}@-webkit-keyframes rotateInDownRight{0%{-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateInDownRight{0%{-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.animate__rotateInDownRight{-webkit-animation-name:rotateInDownRight;animation-name:rotateInDownRight;-webkit-transform-origin:right bottom;transform-origin:right bottom}@-webkit-keyframes rotateInUpLeft{0%{-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateInUpLeft{0%{-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.animate__rotateInUpLeft{-webkit-animation-name:rotateInUpLeft;animation-name:rotateInUpLeft;-webkit-transform-origin:left bottom;transform-origin:left bottom}@-webkit-keyframes rotateInUpRight{0%{-webkit-transform:rotate(-90deg);transform:rotate(-90deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateInUpRight{0%{-webkit-transform:rotate(-90deg);transform:rotate(-90deg);opacity:0}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.animate__rotateInUpRight{-webkit-animation-name:rotateInUpRight;animation-name:rotateInUpRight;-webkit-transform-origin:right bottom;transform-origin:right bottom}@-webkit-keyframes rotateOut{0%{opacity:1}to{-webkit-transform:rotate(200deg);transform:rotate(200deg);opacity:0}}@keyframes rotateOut{0%{opacity:1}to{-webkit-transform:rotate(200deg);transform:rotate(200deg);opacity:0}}.animate__rotateOut{-webkit-animation-name:rotateOut;animation-name:rotateOut;-webkit-transform-origin:center;transform-origin:center}@-webkit-keyframes rotateOutDownLeft{0%{opacity:1}to{-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}}@keyframes rotateOutDownLeft{0%{opacity:1}to{-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}}.animate__rotateOutDownLeft{-webkit-animation-name:rotateOutDownLeft;animation-name:rotateOutDownLeft;-webkit-transform-origin:left bottom;transform-origin:left bottom}@-webkit-keyframes rotateOutDownRight{0%{opacity:1}to{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}@keyframes rotateOutDownRight{0%{opacity:1}to{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}.animate__rotateOutDownRight{-webkit-animation-name:rotateOutDownRight;animation-name:rotateOutDownRight;-webkit-transform-origin:right bottom;transform-origin:right bottom}@-webkit-keyframes rotateOutUpLeft{0%{opacity:1}to{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}@keyframes rotateOutUpLeft{0%{opacity:1}to{-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}.animate__rotateOutUpLeft{-webkit-animation-name:rotateOutUpLeft;animation-name:rotateOutUpLeft;-webkit-transform-origin:left bottom;transform-origin:left bottom}@-webkit-keyframes rotateOutUpRight{0%{opacity:1}to{-webkit-transform:rotate(90deg);transform:rotate(90deg);opacity:0}}@keyframes rotateOutUpRight{0%{opacity:1}to{-webkit-transform:rotate(90deg);transform:rotate(90deg);opacity:0}}.animate__rotateOutUpRight{-webkit-animation-name:rotateOutUpRight;animation-name:rotateOutUpRight;-webkit-transform-origin:right bottom;transform-origin:right bottom}@-webkit-keyframes hinge{0%{-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}20%,60%{-webkit-transform:rotate(80deg);transform:rotate(80deg);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}40%,80%{-webkit-transform:rotate(60deg);transform:rotate(60deg);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;opacity:1}to{-webkit-transform:translate3d(0,700px,0);transform:translate3d(0,700px,0);opacity:0}}@keyframes hinge{0%{-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}20%,60%{-webkit-transform:rotate(80deg);transform:rotate(80deg);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}40%,80%{-webkit-transform:rotate(60deg);transform:rotate(60deg);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;opacity:1}to{-webkit-transform:translate3d(0,700px,0);transform:translate3d(0,700px,0);opacity:0}}.animate__hinge{-webkit-animation-duration:2s;animation-duration:2s;-webkit-animation-duration:calc(var(--animate-duration)*2);animation-duration:calc(var(--animate-duration)*2);-webkit-animation-name:hinge;animation-name:hinge;-webkit-transform-origin:top left;transform-origin:top left}@-webkit-keyframes jackInTheBox{0%{opacity:0;-webkit-transform:scale(.1) rotate(30deg);transform:scale(.1) rotate(30deg);-webkit-transform-origin:center bottom;transform-origin:center bottom}50%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}70%{-webkit-transform:rotate(3deg);transform:rotate(3deg)}to{opacity:1;-webkit-transform:scale(1);transform:scale(1)}}@keyframes jackInTheBox{0%{opacity:0;-webkit-transform:scale(.1) rotate(30deg);transform:scale(.1) rotate(30deg);-webkit-transform-origin:center bottom;transform-origin:center bottom}50%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}70%{-webkit-transform:rotate(3deg);transform:rotate(3deg)}to{opacity:1;-webkit-transform:scale(1);transform:scale(1)}}.animate__jackInTheBox{-webkit-animation-name:jackInTheBox;animation-name:jackInTheBox}@-webkit-keyframes rollIn{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0) rotate(-120deg);transform:translate3d(-100%,0,0) rotate(-120deg)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes rollIn{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0) rotate(-120deg);transform:translate3d(-100%,0,0) rotate(-120deg)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__rollIn{-webkit-animation-name:rollIn;animation-name:rollIn}@-webkit-keyframes rollOut{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0) rotate(120deg);transform:translate3d(100%,0,0) rotate(120deg)}}@keyframes rollOut{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0) rotate(120deg);transform:translate3d(100%,0,0) rotate(120deg)}}.animate__rollOut{-webkit-animation-name:rollOut;animation-name:rollOut}@-webkit-keyframes zoomIn{0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%{opacity:1}}@keyframes zoomIn{0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%{opacity:1}}.animate__zoomIn{-webkit-animation-name:zoomIn;animation-name:zoomIn}@-webkit-keyframes zoomInDown{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInDown{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.animate__zoomInDown{-webkit-animation-name:zoomInDown;animation-name:zoomInDown}@-webkit-keyframes zoomInLeft{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(10px,0,0);transform:scale3d(.475,.475,.475) translate3d(10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInLeft{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(10px,0,0);transform:scale3d(.475,.475,.475) translate3d(10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.animate__zoomInLeft{-webkit-animation-name:zoomInLeft;animation-name:zoomInLeft}@-webkit-keyframes zoomInRight{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInRight{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.animate__zoomInRight{-webkit-animation-name:zoomInRight;animation-name:zoomInRight}@-webkit-keyframes zoomInUp{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInUp{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.animate__zoomInUp{-webkit-animation-name:zoomInUp;animation-name:zoomInUp}@-webkit-keyframes zoomOut{0%{opacity:1}50%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}to{opacity:0}}@keyframes zoomOut{0%{opacity:1}50%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}to{opacity:0}}.animate__zoomOut{-webkit-animation-name:zoomOut;animation-name:zoomOut}@-webkit-keyframes zoomOutDown{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomOutDown{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.animate__zoomOutDown{-webkit-animation-name:zoomOutDown;animation-name:zoomOutDown;-webkit-transform-origin:center bottom;transform-origin:center bottom}@-webkit-keyframes zoomOutLeft{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(42px,0,0);transform:scale3d(.475,.475,.475) translate3d(42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(-2000px,0,0);transform:scale(.1) translate3d(-2000px,0,0)}}@keyframes zoomOutLeft{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(42px,0,0);transform:scale3d(.475,.475,.475) translate3d(42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(-2000px,0,0);transform:scale(.1) translate3d(-2000px,0,0)}}.animate__zoomOutLeft{-webkit-animation-name:zoomOutLeft;animation-name:zoomOutLeft;-webkit-transform-origin:left center;transform-origin:left center}@-webkit-keyframes zoomOutRight{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-42px,0,0);transform:scale3d(.475,.475,.475) translate3d(-42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(2000px,0,0);transform:scale(.1) translate3d(2000px,0,0)}}@keyframes zoomOutRight{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-42px,0,0);transform:scale3d(.475,.475,.475) translate3d(-42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(2000px,0,0);transform:scale(.1) translate3d(2000px,0,0)}}.animate__zoomOutRight{-webkit-animation-name:zoomOutRight;animation-name:zoomOutRight;-webkit-transform-origin:right center;transform-origin:right center}@-webkit-keyframes zoomOutUp{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomOutUp{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.animate__zoomOutUp{-webkit-animation-name:zoomOutUp;animation-name:zoomOutUp;-webkit-transform-origin:center bottom;transform-origin:center bottom}@-webkit-keyframes slideInDown{0%{-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInDown{0%{-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__slideInDown{-webkit-animation-name:slideInDown;animation-name:slideInDown}@-webkit-keyframes slideInLeft{0%{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInLeft{0%{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__slideInLeft{-webkit-animation-name:slideInLeft;animation-name:slideInLeft}@-webkit-keyframes slideInRight{0%{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInRight{0%{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__slideInRight{-webkit-animation-name:slideInRight;animation-name:slideInRight}@-webkit-keyframes slideInUp{0%{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInUp{0%{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.animate__slideInUp{-webkit-animation-name:slideInUp;animation-name:slideInUp}@-webkit-keyframes slideOutDown{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}@keyframes slideOutDown{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}.animate__slideOutDown{-webkit-animation-name:slideOutDown;animation-name:slideOutDown}@-webkit-keyframes slideOutLeft{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}@keyframes slideOutLeft{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}.animate__slideOutLeft{-webkit-animation-name:slideOutLeft;animation-name:slideOutLeft}@-webkit-keyframes slideOutRight{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}@keyframes slideOutRight{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}.animate__slideOutRight{-webkit-animation-name:slideOutRight;animation-name:slideOutRight}@-webkit-keyframes slideOutUp{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}@keyframes slideOutUp{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}.animate__slideOutUp{-webkit-animation-name:slideOutUp;animation-name:slideOutUp}
\ No newline at end of file
diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js
index 5dc6275..6929831 100644
--- a/src/web/routes/admin/content.js
+++ b/src/web/routes/admin/content.js
@@ -501,8 +501,8 @@ module.exports = function registerContentRoutes(app, deps) {
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
- 'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
- [result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
+ 'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
+ [result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await syncPlaylistUploadsOnChange({
@@ -563,8 +563,8 @@ module.exports = function registerContentRoutes(app, deps) {
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
- 'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
- [template.id, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
+ 'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
+ [template.id, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await syncPlaylistUploadsOnChange({
diff --git a/src/web/routes/signage/templates/form-view-model.js b/src/web/routes/signage/templates/form-view-model.js
index b0391ea..e076e6f 100644
--- a/src/web/routes/signage/templates/form-view-model.js
+++ b/src/web/routes/signage/templates/form-view-model.js
@@ -1,6 +1,7 @@
// Shared template form view-model builder.
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
+const animationPresets = require('../../../public/js/templates/animation-presets');
function buildDefaultTemplate() {
return {
@@ -61,7 +62,8 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
cancelUrl: '/templates',
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
canvasSizes: canvasSizes || [],
- scripts: ['js/lib/modal.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
+ animationPresets: animationPresets,
+ scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
};
}
diff --git a/src/web/views/shared/layout.hbs b/src/web/views/shared/layout.hbs
index 80834a2..114e1d2 100644
--- a/src/web/views/shared/layout.hbs
+++ b/src/web/views/shared/layout.hbs
@@ -11,6 +11,7 @@
+
{{#if stylesheets.length}}
{{#each stylesheets}}
diff --git a/src/web/views/signage/templates/animation-advanced-modal.hbs b/src/web/views/signage/templates/animation-advanced-modal.hbs
new file mode 100644
index 0000000..aa31d41
--- /dev/null
+++ b/src/web/views/signage/templates/animation-advanced-modal.hbs
@@ -0,0 +1,100 @@
+{{#> modal-shell modalId="animation-advanced-modal" modalLabelId="animation-advanced-modal-label" modalDialogClass="modal-dialog-centered modal-xl modal-dialog-scrollable" modalBackdropStatic=true modalKeyboardDisabled=true}}
+
+
+
Changes here are saved into the region animation JSON and stay in sync with the preset dropdowns.
+
+
+
+{{/modal-shell}}
\ No newline at end of file
diff --git a/src/web/views/signage/templates/form.hbs b/src/web/views/signage/templates/form.hbs
index efa5719..ed8f321 100644
--- a/src/web/views/signage/templates/form.hbs
+++ b/src/web/views/signage/templates/form.hbs
@@ -31,10 +31,17 @@
+{{> signage/templates/animation-advanced-modal}}
+
\ No newline at end of file
diff --git a/test/client-name-check.test.js b/test/client-name-check.test.js
new file mode 100644
index 0000000..4c33214
--- /dev/null
+++ b/test/client-name-check.test.js
@@ -0,0 +1,91 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const crypto = require('node:crypto');
+
+const {
+ collectLiveConnections,
+ isClientNameAvailable,
+ normalizeClientName,
+ normalizeDeviceId,
+ withClientNameReservation
+} = require('../src/data/client-name-check');
+
+test('normalize helpers trim names and sanitize device ids', () => {
+ assert.equal(normalizeClientName(' Screen A '), 'Screen A');
+ assert.equal(normalizeClientName(''), '');
+ assert.equal(normalizeDeviceId(' device-01 /abc!? '), 'device-01abc');
+ assert.equal(normalizeDeviceId('x'.repeat(200)).length, 128);
+});
+
+test('collectLiveConnections returns an array safely', () => {
+ assert.deepEqual(collectLiveConnections(null), []);
+ assert.deepEqual(collectLiveConnections([{ clientName: 'A' }]), [{ clientName: 'A' }]);
+});
+
+test('isClientNameAvailable rejects matching db rows and live connections', async () => {
+ const pool = {
+ async query(sql, params) {
+ assert.match(sql, /FROM d_onboarding_devices/);
+ assert.deepEqual(params, ['Screen A', 'device-01']);
+ return [[{ device_id: 'device-99' }]];
+ }
+ };
+
+ assert.equal(await isClientNameAvailable(pool, ' Screen A ', 'device-01', []), false);
+ assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-99' }]), false);
+ assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-01' }]), true);
+ assert.equal(await isClientNameAvailable(null, ' ', 'device-01', []), false);
+});
+
+test('withClientNameReservation acquires and releases locks around the handler', async () => {
+ const calls = [];
+ const lockName = `ps_client_name_${crypto.createHash('sha1').update('screen a').digest('hex')}`;
+ const connection = {
+ async query(sql, params) {
+ calls.push({ sql, params });
+ if (sql.includes('GET_LOCK')) {
+ return [[{ lock_result: 1 }]];
+ }
+ return [[{ released: 1 }]];
+ },
+ release() {
+ calls.push({ sql: 'RELEASE_CONNECTION' });
+ }
+ };
+ const pool = {
+ async getConnection() {
+ return connection;
+ }
+ };
+
+ const result = await withClientNameReservation(pool, ' Screen A ', async () => 'ok');
+
+ assert.equal(result, 'ok');
+ assert.deepEqual(calls, [
+ { sql: 'SELECT GET_LOCK(?, 5) AS lock_result', params: [lockName] },
+ { sql: 'SELECT RELEASE_LOCK(?)', params: [lockName] },
+ { sql: 'RELEASE_CONNECTION' }
+ ]);
+});
+
+test('withClientNameReservation rejects busy names', async () => {
+ const connection = {
+ async query(sql) {
+ if (sql.includes('GET_LOCK')) {
+ return [[{ lock_result: 0 }]];
+ }
+ throw new Error('unexpected query');
+ },
+ release() {}
+ };
+ const pool = {
+ async getConnection() {
+ return connection;
+ }
+ };
+
+ await assert.rejects(
+ () => withClientNameReservation(pool, 'Screen A', async () => 'ok'),
+ (error) => error && error.statusCode === 409 && error.message === 'Client name is busy. Please try again.'
+ );
+});
\ No newline at end of file
diff --git a/test/data-utils.test.js b/test/data-utils.test.js
new file mode 100644
index 0000000..b065ff8
--- /dev/null
+++ b/test/data-utils.test.js
@@ -0,0 +1,105 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ buildSearchFilter,
+ buildSortOrderClause,
+ fetchPagedRows,
+ findTopLevelOrderByIndex,
+ normalizePageNumber,
+ normalizeSortDirection,
+ parseJsonSafe,
+ readFormArray
+} = require('../src/data/utils');
+
+test('parseJsonSafe returns null for invalid JSON and preserves objects', () => {
+ assert.equal(parseJsonSafe(''), null);
+ assert.equal(parseJsonSafe('not-json'), null);
+ assert.deepEqual(parseJsonSafe('{"value":true}'), { value: true });
+ const value = { nested: ['a'] };
+ assert.equal(parseJsonSafe(value), value);
+});
+
+test('readFormArray always returns an array shape', () => {
+ assert.deepEqual(readFormArray({}, 'roles'), []);
+ assert.deepEqual(readFormArray({ roles: 'admin' }, 'roles'), ['admin']);
+ assert.deepEqual(readFormArray({ roles: ['admin', 'editor'] }, 'roles'), ['admin', 'editor']);
+});
+
+test('normalize helpers clamp pagination and sort direction', () => {
+ assert.equal(normalizePageNumber('0'), 1);
+ assert.equal(normalizePageNumber('3.9'), 3);
+ assert.equal(normalizePageNumber('abc'), 1);
+ assert.equal(normalizeSortDirection('DESC'), 'desc');
+ assert.equal(normalizeSortDirection('anything else'), 'asc');
+});
+
+test('buildSortOrderClause applies multiple columns and ignores unknown keys', () => {
+ assert.deepEqual(buildSortOrderClause({ name: 'title' }, 'name', 'desc'), {
+ clause: ' ORDER BY title DESC',
+ sortKey: 'name',
+ sortDirection: 'desc'
+ });
+ assert.deepEqual(buildSortOrderClause({ name: ['title', 'id'] }, 'name', 'asc'), {
+ clause: ' ORDER BY title ASC, id ASC',
+ sortKey: 'name',
+ sortDirection: 'asc'
+ });
+ assert.deepEqual(buildSortOrderClause({ name: 'title' }, 'missing', 'desc'), {
+ clause: '',
+ sortKey: 'missing',
+ sortDirection: 'desc'
+ });
+});
+
+test('buildSearchFilter generates escaped LIKE clauses', () => {
+ assert.deepEqual(buildSearchFilter(['name', 'description'], '100%_ready'), {
+ clause: " WHERE (LOWER(COALESCE(CAST(name AS CHAR), '')) LIKE ? ESCAPE '\\\\' OR LOWER(COALESCE(CAST(description AS CHAR), '')) LIKE ? ESCAPE '\\\\')",
+ params: ['%100\\%\\_ready%', '%100\\%\\_ready%']
+ });
+ assert.deepEqual(buildSearchFilter([], 'anything'), { clause: '', params: [] });
+});
+
+test('findTopLevelOrderByIndex skips nested subqueries', () => {
+ const sql = 'SELECT * FROM (SELECT * FROM items ORDER BY created_at DESC) AS nested ORDER BY id ASC';
+ assert.equal(findTopLevelOrderByIndex(sql), sql.lastIndexOf('ORDER BY id ASC'));
+});
+
+test('fetchPagedRows combines search, ordering, and pagination parameters', async () => {
+ const queries = [];
+ const pool = {
+ async query(sql, params) {
+ queries.push({ sql, params });
+ if (sql.startsWith('SELECT COUNT(*)')) {
+ return [[{ count: 3 }]];
+ }
+ return [[{ id: 1 }]];
+ }
+ };
+
+ const result = await fetchPagedRows(pool, {
+ selectSql: 'SELECT id, name FROM items WHERE active = 1 ORDER BY name ASC',
+ countSql: 'SELECT COUNT(*) AS count FROM items WHERE active = 1',
+ params: ['active'],
+ searchColumns: ['name'],
+ searchTerm: 'alpha',
+ sortColumns: { name: 'name' },
+ sortKey: 'name',
+ sortDirection: 'desc',
+ pageSize: 2,
+ page: 2
+ });
+
+ assert.deepEqual(result, {
+ rows: [{ id: 1 }],
+ totalItems: 3,
+ totalPages: 2,
+ currentPage: 2,
+ pageSize: 2
+ });
+ assert.equal(queries.length, 2);
+ assert.equal(queries[0].sql, "SELECT COUNT(*) AS count FROM (SELECT id, name FROM items WHERE active = 1 AND (LOWER(COALESCE(CAST(name AS CHAR), '')) LIKE ? ESCAPE '\\\\')) AS filtered_rows");
+ assert.deepEqual(queries[0].params, ['active', '%alpha%']);
+ assert.equal(queries[1].sql, "SELECT id, name FROM items WHERE active = 1 AND (LOWER(COALESCE(CAST(name AS CHAR), '')) LIKE ? ESCAPE '\\\\') ORDER BY name DESC LIMIT ? OFFSET ?");
+ assert.deepEqual(queries[1].params, ['active', '%alpha%', 2, 2]);
+});
\ No newline at end of file
diff --git a/test/player-playlist.test.js b/test/player-playlist.test.js
new file mode 100644
index 0000000..f2d0472
--- /dev/null
+++ b/test/player-playlist.test.js
@@ -0,0 +1,148 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+
+const { createPlayerPlaylistService } = require('../src/player/playlist');
+
+function createFsStub() {
+ const original = {
+ readFile: fs.promises.readFile,
+ writeFile: fs.promises.writeFile,
+ mkdir: fs.promises.mkdir
+ };
+ const calls = [];
+
+ fs.promises.readFile = async function (filePath) {
+ calls.push({ method: 'readFile', filePath });
+ if (filePath.endsWith('fallback.json')) {
+ return JSON.stringify({ fromSnapshot: true, screen: { slug: 'test2' } });
+ }
+ const error = new Error('missing');
+ error.code = 'ENOENT';
+ throw error;
+ };
+
+ fs.promises.writeFile = async function (filePath, content) {
+ calls.push({ method: 'writeFile', filePath, content });
+ };
+
+ fs.promises.mkdir = async function (dirPath, options) {
+ calls.push({ method: 'mkdir', dirPath, options });
+ };
+
+ return {
+ calls,
+ restore() {
+ fs.promises.readFile = original.readFile;
+ fs.promises.writeFile = original.writeFile;
+ fs.promises.mkdir = original.mkdir;
+ }
+ };
+}
+
+test('buildScreenPlaylist assembles slides, templates, and derived values', async () => {
+ const pool = {
+ async query(sql, params) {
+ if (sql.includes('FROM d_screens')) {
+ assert.deepEqual(params, ['test2']);
+ return [[{ id: 7, name: 'Screen 7', slug: 'test2', playlist_id: 22, modified_at: '2026-08-03T00:00:00.000Z' }]];
+ }
+ if (sql.includes('FROM c_playlists')) {
+ assert.deepEqual(params, [22]);
+ return [[{ id: 22, name: 'Playlist 22', fade_between_slides: 1, skip_unavailable_rtmp: 0, modified_at: '2026-08-02T00:00:00.000Z' }]];
+ }
+ if (sql.includes('FROM c_playlist_slides ps')) {
+ return [[{
+ id: 101,
+ title: 'Intro',
+ template_id: 33,
+ content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"textRegion":{"type":"text","value":"Hello"}}',
+ modified_at: '2026-08-03T00:00:01.000Z',
+ position: 1,
+ duration_seconds: 9,
+ use_video_duration: 1,
+ disable_audio: null,
+ template_name: 'Template 33',
+ canvas_size_name: 'HD',
+ canvas_size_width: 1920,
+ canvas_size_height: 1080,
+ canvas_width: 1920,
+ canvas_height: 1080
+ }]];
+ }
+ if (sql.includes('FROM c_playlist_slide_schedule_rules')) {
+ return [[
+ { id: 500, playlist_slide_id: 101, position: 1, start_datetime: null, end_datetime: null, start_time: '08:00', end_time: '12:00', schedule_days_json: '[1,2,3]' },
+ { id: 501, playlist_slide_id: 101, position: 2, start_datetime: null, end_datetime: null, start_time: '13:00', end_time: '17:00', schedule_days_json: '[4,5]' }
+ ]];
+ }
+ if (sql.includes('FROM c_templates st')) {
+ assert.deepEqual(params, [[33]]);
+ return [[{ id: 33, name: 'Template 33', canvas_size_id: 4, canvas_size_width: 1920, canvas_size_height: 1080, background_image_path: '/media/bg.png', background_color: '#111111', modified_at: '2026-08-03T00:00:02.000Z' }]];
+ }
+ if (sql.includes('FROM c_template_regions')) {
+ assert.deepEqual(params, [[33]]);
+ return [[{ id: 900, template_id: 33, region_key: 'textRegion', region_type: 'text', label: 'Text Region', font_family: 'Arial', x: 10, y: 20, width: 300, height: 200, z_index: 1, modified_at: '2026-08-03T00:00:03.000Z' }]];
+ }
+ throw new Error(`unexpected query: ${sql}`);
+ }
+ };
+ const common = {
+ parseJsonSafe(value) {
+ return JSON.parse(value);
+ },
+ fetchRssFeedsData: async () => ({ rssFeeds: [{ id: 1, title: 'Feed 1' }] }),
+ fetchRssFeedItemsByFeedId: async () => ([{ title: 'Item 1', link: 'https://example.com' }]),
+ normalizeRssFeedItem(item) {
+ return Object.assign({}, item, { normalized: true });
+ },
+ fetchApiSourcesData: async () => ({ apiSources: [{ id: 2, name: 'Source 2', last_response_json: '{"ok":true}' }] }),
+ fetchTimetablesData: async () => ({ timetableGroups: [{ id: 3, name: 'Group 3' }] })
+ };
+ const fsStub = createFsStub();
+
+ try {
+ const service = createPlayerPlaylistService({ pool, common, snapshotDir: 'C:\\tmp\\snapshots' });
+ const payload = await service.buildScreenPlaylist('test2');
+
+ assert.equal(payload.screen.slug, 'test2');
+ assert.equal(payload.playlist.name, 'Playlist 22');
+ assert.equal(payload.slides[0].duration_seconds, 12.346);
+ assert.equal(payload.slides[0].disable_audio, true);
+ assert.equal(payload.slides[0].content.videoRegion.disable_audio, true);
+ assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z');
+ assert.equal(payload.slides[0].scheduleRules.length, 2);
+ assert.equal(payload.slides[0].template.regions[0].label, 'Text Region');
+ assert.equal(payload.rssFeeds[0].items[0].normalized, true);
+ assert.deepEqual(payload.apiSources[0].responseJson, { ok: true });
+ assert.deepEqual(payload.timetableGroups, [{ id: 3, name: 'Group 3' }]);
+ assert.match(payload.revision, /^[a-f0-9]{40}$/);
+ assert.ok(fsStub.calls.some((call) => call.method === 'writeFile'));
+ } finally {
+ fsStub.restore();
+ }
+});
+
+test('buildScreenPlaylist falls back to a snapshot when queries fail', async () => {
+ const pool = {
+ async query() {
+ throw new Error('database unavailable');
+ }
+ };
+ const common = {
+ parseJsonSafe() {
+ throw new Error('not expected');
+ }
+ };
+ const fsStub = createFsStub();
+
+ try {
+ const service = createPlayerPlaylistService({ pool, common, snapshotDir: 'C:\\tmp\\snapshots' });
+ const payload = await service.buildScreenPlaylist('fallback');
+
+ assert.deepEqual(payload, { fromSnapshot: true, screen: { slug: 'test2' } });
+ assert.ok(fsStub.calls.some((call) => call.method === 'readFile'));
+ } finally {
+ fsStub.restore();
+ }
+});
\ No newline at end of file
diff --git a/test/player-render-helpers.test.js b/test/player-render-helpers.test.js
new file mode 100644
index 0000000..a10a8cb
--- /dev/null
+++ b/test/player-render-helpers.test.js
@@ -0,0 +1,81 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+require('../src/common');
+
+const {
+ mediaKind,
+ normalizeSlide,
+ renderEditorJsContent,
+ sanitizeRichText
+} = require('../src/player/render-helpers');
+
+test('mediaKind classifies player media by extension', () => {
+ assert.equal(mediaKind('poster.PNG'), 'image');
+ assert.equal(mediaKind('intro.mp4'), 'video');
+ assert.equal(mediaKind('manual.pdf'), 'pdf');
+ assert.equal(mediaKind('notes.txt'), 'file');
+});
+
+test('sanitizeRichText strips unsafe content but preserves allowed markup', () => {
+ const html = '';
+
+ assert.equal(
+ sanitizeRichText(html),
+ ''
+ );
+});
+
+test('normalizeSlide normalizes nested content without mutating the source', () => {
+ const slide = {
+ id: 12,
+ content: {
+ hero: {
+ value: '{"headline":"Hello"}',
+ font_family: ' Open Sans! ',
+ font_size: '42',
+ font_color: 'not-a-color',
+ type: 'text'
+ },
+ footer: 'plain text'
+ }
+ };
+
+ const normalized = normalizeSlide(slide);
+
+ assert.notEqual(normalized.content, slide.content);
+ assert.deepEqual(normalized.content.hero, {
+ value: { headline: 'Hello' },
+ font_family: ' Open Sans! ',
+ font_size: '42',
+ font_color: 'not-a-color',
+ type: 'text'
+ });
+ assert.deepEqual(normalized.content.footer, {
+ type: 'text',
+ value: 'plain text'
+ });
+ assert.deepEqual(slide.content.hero, {
+ value: '{"headline":"Hello"}',
+ font_family: ' Open Sans! ',
+ font_size: '42',
+ font_color: 'not-a-color',
+ type: 'text'
+ });
+});
+
+test('renderEditorJsContent sanitizes editor blocks and wraps legacy text', () => {
+ const editorJson = {
+ blocks: [
+ { type: 'header', data: { level: 2, text: 'Title ' } },
+ { type: 'paragraph', data: { text: 'bad ok ' } },
+ { type: 'list', data: { style: 'ordered', items: ['One', { text: 'Two ' }] } }
+ ]
+ };
+
+ assert.equal(
+ renderEditorJsContent(editorJson),
+ 'Title bad ok
One Two '
+ );
+ assert.equal(renderEditorJsContent('plain text'), 'plain text
');
+});
\ No newline at end of file
diff --git a/test/player-routes.test.js b/test/player-routes.test.js
new file mode 100644
index 0000000..70768c5
--- /dev/null
+++ b/test/player-routes.test.js
@@ -0,0 +1,396 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+require('../src/common');
+
+const { registerPlayerRoutes } = require('../src/player/routes');
+
+function createAppAndHandlers() {
+ const handlers = {};
+ const app = {
+ use() {},
+ get(path, ...routeHandlers) {
+ handlers[path] = function (req, res) {
+ return new Promise((resolve, reject) => {
+ const runHandler = (index) => {
+ const handler = routeHandlers[index];
+ if (!handler) {
+ resolve();
+ return;
+ }
+
+ let nextCalled = false;
+ const next = function () {
+ nextCalled = true;
+ return runHandler(index + 1);
+ };
+
+ let result;
+ try {
+ result = handler(req, res, next);
+ } catch (error) {
+ reject(error);
+ return;
+ }
+
+ Promise.resolve(result).then(function (value) {
+ if (!nextCalled) {
+ resolve(value);
+ }
+ }, reject);
+ };
+
+ runHandler(0);
+ });
+ };
+ },
+ post() {},
+ put() {},
+ delete() {}
+ };
+
+ return { app, handlers };
+}
+
+function createResponse() {
+ const headers = {};
+ return {
+ headers,
+ statusCode: 200,
+ body: null,
+ ended: false,
+ set(name, value) {
+ headers[name] = value;
+ return this;
+ },
+ status(code) {
+ this.statusCode = code;
+ return this;
+ },
+ json(value) {
+ this.body = value;
+ return this;
+ },
+ send(value) {
+ this.body = value;
+ return this;
+ },
+ end() {
+ this.ended = true;
+ return this;
+ }
+ };
+}
+
+function createPlayerRouteOptions(overrides) {
+ return Object.assign({
+ mediaDir: 'e:\\Projects Git\\pulse-signage\\media',
+ assetDir: 'e:\\Projects Git\\pulse-signage\\src\\player\\public',
+ playerRuntime: { broadcastAnnouncementRefresh() {}, snapshotConnections() { return []; } },
+ playerPlaylistService: { async buildScreenPlaylist() { return { screen: { slug: 'test2' } }; } },
+ rtmpStreamService: {
+ async getSessionStatus() { return { ready: false, live: false, session: {} }; },
+ async getManifestFilePath() { return null; },
+ async getSegmentFilePath() { return null; }
+ },
+ playerIdentifier: ''
+ }, overrides);
+}
+
+test('screen route falls back to offline rendering when playlist build fails', async () => {
+ const { app, handlers } = createAppAndHandlers();
+ const renderCalls = [];
+ const originalConsoleError = console.error;
+ console.error = () => {};
+ const pool = {
+ async query() {
+ return [[{ id: 1 }]];
+ },
+ async getConnection() {
+ return {
+ async beginTransaction() {},
+ async query() {},
+ async commit() {},
+ async rollback() {},
+ release() {}
+ };
+ }
+ };
+ const common = {
+ renderPlayerPage(slug, data) {
+ renderCalls.push({ slug, data });
+ return data ? 'online' : 'offline';
+ }
+ };
+
+ registerPlayerRoutes(app, {
+ app,
+ pool,
+ common,
+ ...createPlayerRouteOptions({
+ playerPlaylistService: {
+ async buildScreenPlaylist() {
+ const error = new Error('db unavailable');
+ error.code = 'ECONNREFUSED';
+ throw error;
+ }
+ }
+ })
+ });
+
+ const handler = handlers['/screen/:slug'];
+ assert.equal(typeof handler, 'function');
+
+ const res = createResponse();
+
+ try {
+ await handler({ params: { slug: 'test2' } }, res);
+ await new Promise((resolve) => setImmediate(resolve));
+
+ assert.equal(res.headers['X-Player-Offline'], '1');
+ assert.equal(res.body, 'offline');
+ assert.deepEqual(renderCalls, [
+ { slug: 'test2', data: null }
+ ]);
+ } finally {
+ console.error = originalConsoleError;
+ }
+});
+
+test('screen route renders the shell when playlist data is missing', async () => {
+ const { app, handlers } = createAppAndHandlers();
+ const renderCalls = [];
+ const pool = {
+ async query() {
+ return [[{ id: 1 }]];
+ },
+ async getConnection() {
+ return {
+ async beginTransaction() {},
+ async query() {},
+ async commit() {},
+ async rollback() {},
+ release() {}
+ };
+ }
+ };
+ const common = {
+ renderPlayerPage(slug, data) {
+ renderCalls.push({ slug, data });
+ return '';
+ }
+ };
+
+ registerPlayerRoutes(app, {
+ app,
+ pool,
+ common,
+ ...createPlayerRouteOptions({
+ playerPlaylistService: {
+ async buildScreenPlaylist() {
+ return {
+ screen: { id: 7, slug: 'test2' },
+ playlist: null,
+ slides: [],
+ rssFeeds: [],
+ apiSources: [],
+ timetableGroups: [],
+ revision: 'abc123'
+ };
+ }
+ }
+ })
+ });
+
+ const handler = handlers['/screen/:slug'];
+ const res = createResponse();
+
+ await handler({ params: { slug: 'test2' } }, res);
+
+ assert.equal(res.statusCode, 200);
+ assert.match(String(res.body), /Loading screen/);
+ assert.deepEqual(renderCalls, [
+ {
+ slug: 'test2',
+ data: {
+ screen: { id: 7, slug: 'test2' },
+ playlist: null,
+ slides: [],
+ rssFeeds: [],
+ apiSources: [],
+ timetableGroups: [],
+ revision: 'abc123'
+ }
+ }
+ ]);
+});
+
+test('playlist api route returns 404, etag, and 304 responses', async () => {
+ const { app, handlers } = createAppAndHandlers();
+ const playlistData = {
+ screen: { id: 7, slug: 'test2' },
+ playlist: { id: 22 },
+ slides: [],
+ rssFeeds: [],
+ apiSources: [],
+ timetableGroups: [],
+ revision: 'rev-123'
+ };
+
+ registerPlayerRoutes(app, {
+ app,
+ pool: { async query() { return [[{ id: 1 }]]; } },
+ common: { renderPlayerPage() { return ''; } },
+ ...createPlayerRouteOptions({
+ playerPlaylistService: {
+ async buildScreenPlaylist(slug) {
+ if (slug === 'missing') {
+ return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [], revision: 'none' };
+ }
+ return playlistData;
+ }
+ }
+ })
+ });
+
+ const handler = handlers['/api/screens/:slug/playlist'];
+ assert.equal(typeof handler, 'function');
+
+ const missingRes = createResponse();
+ await handler({ params: { slug: 'missing' }, headers: {} }, missingRes);
+ assert.equal(missingRes.statusCode, 404);
+ assert.deepEqual(missingRes.body, { error: 'Screen not found' });
+
+ const okRes = createResponse();
+ await handler({ params: { slug: 'test2' }, headers: {} }, okRes);
+ assert.equal(okRes.statusCode, 200);
+ assert.equal(okRes.headers.ETag, '"rev-123"');
+ assert.deepEqual(okRes.body, playlistData);
+
+ const notModifiedRes = createResponse();
+ await handler({ params: { slug: 'test2' }, headers: { 'if-none-match': '"rev-123"' } }, notModifiedRes);
+ assert.equal(notModifiedRes.statusCode, 304);
+ assert.equal(notModifiedRes.ended, true);
+});
+
+test('announcement route returns 503 for transient failures and 304 on matching etag', async () => {
+ const { app, handlers } = createAppAndHandlers();
+ const announcement = {
+ id: 9,
+ modified_at: '2026-08-03T00:00:00.000Z',
+ expires_at: '2026-08-04T00:00:00.000Z',
+ enabled: true
+ };
+
+ registerPlayerRoutes(app, {
+ app,
+ pool: { async query() { return [[{ id: 1 }]]; } },
+ common: {
+ async fetchActiveAnnouncement() {
+ const error = new Error('db unavailable');
+ error.code = 'ECONNREFUSED';
+ throw error;
+ },
+ renderPlayerPage() { return ''; }
+ },
+ ...createPlayerRouteOptions({
+ rtmpStreamService: {
+ async getSessionStatus() { return { ready: false, live: false, session: {} }; },
+ async getManifestFilePath() { return null; },
+ async getSegmentFilePath() { return null; }
+ }
+ })
+ });
+
+ const handler = handlers['/api/screens/:slug/announcement'];
+ const unavailableRes = createResponse();
+ await handler({ params: { slug: 'test2' }, headers: {} }, unavailableRes);
+ assert.equal(unavailableRes.statusCode, 503);
+ assert.deepEqual(unavailableRes.body, { error: 'Announcement state unavailable.' });
+
+ registerPlayerRoutes(app, {
+ app,
+ pool: { async query() { return [[{ id: 1 }]]; } },
+ common: {
+ async fetchActiveAnnouncement() {
+ return announcement;
+ },
+ renderPlayerPage() { return ''; }
+ },
+ ...createPlayerRouteOptions({
+ rtmpStreamService: {
+ async getSessionStatus() { return { ready: false, live: false, session: {} }; },
+ async getManifestFilePath() { return null; },
+ async getSegmentFilePath() { return null; }
+ }
+ })
+ });
+
+ const okHandler = handlers['/api/screens/:slug/announcement'];
+ const okRes = createResponse();
+ await okHandler({ params: { slug: 'test2' }, headers: {} }, okRes);
+ assert.equal(okRes.statusCode, 200);
+ assert.equal(okRes.headers.ETag, '"9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1"');
+ assert.deepEqual(okRes.body, { announcement, revision: '9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1' });
+
+ const notModifiedRes = createResponse();
+ await okHandler({ params: { slug: 'test2' }, headers: { 'if-none-match': '"9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1"' } }, notModifiedRes);
+ assert.equal(notModifiedRes.statusCode, 304);
+ assert.equal(notModifiedRes.ended, true);
+});
+
+test('rtmp session route returns not-ready and ready payloads', async () => {
+ const { app, handlers } = createAppAndHandlers();
+ const sessionCalls = [];
+
+ registerPlayerRoutes(app, {
+ app,
+ pool: { async query() { return [[{ id: 1 }]]; } },
+ common: { renderPlayerPage() { return ''; } },
+ ...createPlayerRouteOptions({
+ rtmpStreamService: {
+ async getSessionStatus(source, useMutedOutput) {
+ sessionCalls.push({ source, useMutedOutput });
+ if (source === 'ready') {
+ return {
+ ready: true,
+ live: true,
+ session: { key: 'abc', playlistUrl: 'http://example.com/live.m3u8', disableAudio: useMutedOutput }
+ };
+ }
+ return {
+ ready: false,
+ live: false,
+ timedOut: true,
+ stderr: 'starting',
+ session: {}
+ };
+ },
+ async getManifestFilePath() { return null; },
+ async getSegmentFilePath() { return null; }
+ }
+ })
+ });
+
+ const handler = handlers['/api/rtmp/session'];
+
+ const unavailableRes = createResponse();
+ await handler({ headers: {}, query: { source: 'cold', disableAudio: 'true' } }, unavailableRes);
+ assert.equal(unavailableRes.statusCode, 503);
+ assert.deepEqual(unavailableRes.body, { ready: false, live: false, timedOut: true, stderr: 'starting' });
+
+ const readyRes = createResponse();
+ await handler({ headers: {}, query: { source: 'ready', disableAudio: 'yes' } }, readyRes);
+ assert.equal(readyRes.statusCode, 200);
+ assert.deepEqual(readyRes.body, {
+ key: 'abc',
+ playlistUrl: 'http://example.com/live.m3u8',
+ disableAudio: true,
+ ready: true,
+ live: true
+ });
+ assert.deepEqual(sessionCalls, [
+ { source: 'cold', useMutedOutput: true },
+ { source: 'ready', useMutedOutput: true }
+ ]);
+});
\ No newline at end of file
diff --git a/test/player-runtime.test.js b/test/player-runtime.test.js
new file mode 100644
index 0000000..623c6c9
--- /dev/null
+++ b/test/player-runtime.test.js
@@ -0,0 +1,156 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const http = require('http');
+const WebSocket = require('ws');
+
+require('../src/common');
+
+const { createPageAuthToken } = require('../src/request-auth');
+const { createPlayerRuntime } = require('../src/player/runtime');
+
+const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
+
+test.after(() => {
+ if (originalSecret === undefined) {
+ delete process.env.PULSE_SIGNAGE_SHARED_SECRET;
+ } else {
+ process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
+ }
+});
+
+function waitFor(predicate, timeoutMs = 1000) {
+ const start = Date.now();
+ return new Promise((resolve, reject) => {
+ const tick = () => {
+ const value = predicate();
+ if (value) {
+ return resolve(value);
+ }
+ if (Date.now() - start > timeoutMs) {
+ return reject(new Error('Timed out waiting for runtime state.'));
+ }
+ setTimeout(tick, 25);
+ };
+ tick();
+ });
+}
+
+test('player runtime snapshots websocket state and checks live names', async () => {
+ process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
+
+ const runtime = createPlayerRuntime({ pool: null });
+ const server = http.createServer();
+ runtime.installWebsocket(server);
+
+ await new Promise((resolve) => server.listen(0, resolve));
+ const { port } = server.address();
+ const token = createPageAuthToken({ scope: 'player', slug: 'test2' });
+ const client = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`);
+
+ try {
+ await new Promise((resolve, reject) => {
+ client.once('open', resolve);
+ client.once('error', reject);
+ });
+
+ client.send(JSON.stringify({
+ type: 'state',
+ clientId: 'client-1',
+ clientName: ' Lobby Player ',
+ deviceId: 'device 123!?',
+ userAgent: 'Mozilla/5.0 (unit test)',
+ viewport: { width: 1920, height: 1080 },
+ page: 'http://localhost:8081/screen/test2',
+ paused: true,
+ blackout: false,
+ currentSlide: { id: 9, title: 'Intro', kind: 'slide', playlistSignature: 'sig' }
+ }));
+
+ await waitFor(() => {
+ const snapshot = runtime.snapshotConnections('test2')[0];
+ return snapshot && snapshot.clientName === 'Lobby Player' ? snapshot : null;
+ });
+ const snapshot = runtime.snapshotConnections('test2')[0];
+
+ assert.equal(snapshot.clientName, 'Lobby Player');
+ assert.equal(snapshot.deviceId, 'device123');
+ assert.match(snapshot.label, /Lobby Player/);
+ assert.equal(snapshot.paused, true);
+ assert.equal(snapshot.currentSlideId, 9);
+ assert.equal(snapshot.currentSlideTitle, 'Intro');
+ assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'other-device'), false);
+ assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'device123'), true);
+ assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Other Name', 'device123'), true);
+ } finally {
+ client.close();
+ await new Promise((resolve) => server.close(resolve));
+ }
+});
+
+test('player runtime sends targeted and broadcast commands to live sockets', async () => {
+ process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
+
+ const runtime = createPlayerRuntime({ pool: null });
+ const server = http.createServer();
+ runtime.installWebsocket(server);
+
+ await new Promise((resolve) => server.listen(0, resolve));
+ const { port } = server.address();
+ const token = createPageAuthToken({ scope: 'player', slug: 'test2' });
+ const clientA = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`);
+ const clientB = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`);
+
+ function openClient(client) {
+ return new Promise((resolve, reject) => {
+ client.once('open', resolve);
+ client.once('error', reject);
+ });
+ }
+
+ function waitForMessage(client) {
+ return new Promise((resolve) => {
+ client.once('message', (raw) => {
+ resolve(JSON.parse(String(raw)));
+ });
+ });
+ }
+
+ try {
+ await Promise.all([openClient(clientA), openClient(clientB)]);
+
+ clientA.send(JSON.stringify({ type: 'state', clientId: 'client-a', clientName: 'Alpha', deviceId: 'device-a', page: 'http://localhost:8081/screen/test2' }));
+ clientB.send(JSON.stringify({ type: 'state', clientId: 'client-b', clientName: 'Beta', deviceId: 'device-b', page: 'http://localhost:8081/screen/test2' }));
+
+ await waitFor(() => {
+ const connections = runtime.snapshotConnections('test2');
+ return connections.length === 2 && connections.every((connection) => connection.clientName);
+ });
+ const [firstConnection, secondConnection] = runtime.snapshotConnections('test2');
+
+ const targetedMessagePromise = waitForMessage(clientA);
+ const targetedCount = await runtime.sendCommandToConnection('test2', firstConnection.id, { action: 'pause' });
+ assert.equal(targetedCount, 1);
+ const targetedMessage = await targetedMessagePromise;
+ assert.equal(targetedMessage.type, 'command');
+ assert.equal(targetedMessage.action, 'pause');
+ assert.equal(targetedMessage.targetConnectionId, firstConnection.id);
+
+ const broadcastPromises = [waitForMessage(clientA), waitForMessage(clientB)];
+ const broadcastCount = await runtime.broadcastCommand('test2', 'resume');
+ assert.equal(broadcastCount, 2);
+ const [broadcastA, broadcastB] = await Promise.all(broadcastPromises);
+ assert.equal(broadcastA.type, 'command');
+ assert.equal(broadcastA.command, 'resume');
+ assert.equal(broadcastB.type, 'command');
+ assert.equal(broadcastB.command, 'resume');
+ assert.equal(broadcastA.targetConnectionId, undefined);
+ assert.equal(broadcastB.targetConnectionId, undefined);
+
+ assert.equal(await runtime.sendCommandToConnection('test2', 'missing', 'pause'), 0);
+ assert.equal(secondConnection.clientName, 'Beta');
+ } finally {
+ clientA.close();
+ clientB.close();
+ await new Promise((resolve) => server.close(resolve));
+ }
+});
\ No newline at end of file
diff --git a/test/request-auth.test.js b/test/request-auth.test.js
new file mode 100644
index 0000000..6e9a0ac
--- /dev/null
+++ b/test/request-auth.test.js
@@ -0,0 +1,103 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ createPageAuthBundle,
+ createPageAuthToken,
+ createPageFetchAuthScript,
+ createRequestAuthHeaders,
+ verifyPageAuthToken,
+ verifyRequestAuth,
+ PAGE_TOKEN_HEADER,
+ REQUEST_SIGNATURE_HEADER,
+ REQUEST_TIMESTAMP_HEADER
+} = require('../src/request-auth');
+
+const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
+
+function setSecret(value) {
+ process.env.PULSE_SIGNAGE_SHARED_SECRET = value;
+}
+
+test.after(() => {
+ if (originalSecret === undefined) {
+ delete process.env.PULSE_SIGNAGE_SHARED_SECRET;
+ } else {
+ process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
+ }
+});
+
+test('request auth headers verify empty parser bodies as null payloads', () => {
+ setSecret('test-secret');
+
+ const originalNow = Date.now;
+ Date.now = () => 1700000000000;
+ try {
+ const headers = createRequestAuthHeaders({ method: 'POST', pathname: '/api/media/config', body: null, timestamp: 1700000000000 });
+ const req = {
+ method: 'POST',
+ path: '/api/media/config',
+ body: {},
+ headers: {
+ [REQUEST_TIMESTAMP_HEADER]: headers[REQUEST_TIMESTAMP_HEADER],
+ [REQUEST_SIGNATURE_HEADER]: headers[REQUEST_SIGNATURE_HEADER]
+ }
+ };
+
+ assert.equal(verifyRequestAuth(req), true);
+ } finally {
+ Date.now = originalNow;
+ }
+});
+
+test('page auth bundles round-trip and expire as expected', () => {
+ setSecret('test-secret');
+
+ const originalNow = Date.now;
+ Date.now = () => 1700000000000;
+ try {
+ const bundle = createPageAuthBundle({ scope: 'player', slug: 'test2' });
+ assert.equal(typeof bundle.token, 'string');
+ assert.equal(bundle.issuedAt, 1700000000000);
+ assert.equal(bundle.expiresAt, 1700043200000);
+ assert.deepEqual(verifyPageAuthToken(bundle.token), {
+ scope: 'player',
+ slug: 'test2',
+ issuedAt: 1700000000000,
+ expiresAt: 1700043200000
+ });
+ assert.equal(createPageAuthToken({ scope: 'player' }).split('.').length, 2);
+
+ Date.now = () => 1700043200001;
+ assert.equal(verifyPageAuthToken(bundle.token), null);
+ } finally {
+ Date.now = originalNow;
+ }
+});
+
+test('request auth rejects mismatched signatures', () => {
+ setSecret('test-secret');
+
+ const headers = createRequestAuthHeaders({ method: 'PUT', pathname: '/api/media/config', body: { hello: 'world' }, timestamp: 1700000000000 });
+ const req = {
+ method: 'PUT',
+ path: '/api/media/config',
+ body: { hello: 'changed' },
+ headers: {
+ [REQUEST_TIMESTAMP_HEADER]: headers[REQUEST_TIMESTAMP_HEADER],
+ [REQUEST_SIGNATURE_HEADER]: headers[REQUEST_SIGNATURE_HEADER]
+ }
+ };
+
+ assert.equal(verifyRequestAuth(req), false);
+ assert.equal(PAGE_TOKEN_HEADER, 'x-pulse-page-auth');
+});
+
+test('page fetch auth script injects renew and header logic', () => {
+ const script = createPageFetchAuthScript({ token: 'abc123', expiresAt: 1700000000000 });
+
+ assert.match(script, /window\.__pulsePageAuthToken = pageAuthToken/);
+ assert.match(script, /"x-pulse-page-auth"/);
+ assert.match(script, /\/api\/auth\/page/);
+ assert.match(script, /abc123/);
+});
\ No newline at end of file
diff --git a/test/templates.test.js b/test/templates.test.js
new file mode 100644
index 0000000..11bb50a
--- /dev/null
+++ b/test/templates.test.js
@@ -0,0 +1,109 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ buildTemplatePayload,
+ extractTemplateRegions
+} = require('../src/data/templates');
+
+test('extractTemplateRegions normalizes JSON regions and filters invalid rows', () => {
+ const regions = extractTemplateRegions({
+ regions_json: JSON.stringify([
+ {
+ region_name: 'Hero',
+ region_type: 'TEXT',
+ lock_ratio: '16 : 9',
+ animation_json: '{"intro":{"preset":"fadeIn"},"out":{"preset":"bounceOut"}}',
+ x: '10',
+ y: '20',
+ width: '320',
+ height: '180',
+ z_index: '2'
+ },
+ {
+ region_name: ' ',
+ region_type: 'image'
+ }
+ ])
+ });
+
+ assert.deepEqual(regions, [{
+ region_key: 'Hero',
+ region_type: 'TEXT',
+ label: 'Hero',
+ lock_ratio: '16:9',
+ animation_json: {
+ intro: { preset: 'fadeIn' },
+ outro: { preset: 'bounceOut' },
+ loop: { preset: 'none' }
+ },
+ x: 10,
+ y: 20,
+ width: 320,
+ height: 180,
+ z_index: 2
+ }]);
+});
+
+test('buildTemplatePayload resolves canvas size and rejects duplicate region names', async () => {
+ const pool = {
+ async query(sql, params) {
+ if (sql.includes('FROM c_canvas_sizes WHERE id = ?')) {
+ assert.deepEqual(params, [4]);
+ return [[{ id: 4, name: 'HD', width: 1280, height: 720 }]];
+ }
+ throw new Error(`unexpected query: ${sql}`);
+ }
+ };
+
+ const payload = await buildTemplatePayload(pool, {
+ body: {
+ name: ' Main Template ',
+ canvas_size_id: '4',
+ background_color: 'not-a-color',
+ regions_json: JSON.stringify([
+ { region_name: 'Header', region_type: 'text', lock_ratio: '4:3' }
+ ])
+ },
+ files: []
+ }, null);
+
+ assert.deepEqual(payload, {
+ name: 'Main Template',
+ canvasSizeId: 4,
+ canvasSizeWidth: 1280,
+ canvasSizeHeight: 720,
+ backgroundImagePath: null,
+ backgroundColor: '#111111',
+ regions: [{
+ region_key: 'Header',
+ region_type: 'text',
+ label: 'Header',
+ lock_ratio: '4:3',
+ animation_json: {
+ intro: { preset: 'none' },
+ outro: { preset: 'none' },
+ loop: { preset: 'none' }
+ },
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ z_index: 0
+ }]
+ });
+
+ await assert.rejects(
+ () => buildTemplatePayload(pool, {
+ body: {
+ name: 'Dupes',
+ regions_json: JSON.stringify([
+ { region_name: 'One' },
+ { region_name: 'one' }
+ ])
+ },
+ files: []
+ }, null),
+ (error) => error && error.statusCode === 400 && error.message === 'Region names must be unique on this template.'
+ );
+});
\ No newline at end of file
diff --git a/test/web-manage-routes.test.js b/test/web-manage-routes.test.js
new file mode 100644
index 0000000..d09d10d
--- /dev/null
+++ b/test/web-manage-routes.test.js
@@ -0,0 +1,96 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const registerManageRoutes = require('../src/web/routes/admin/manage');
+
+test('screen update redirects and forwards redirect when the slug changes', async () => {
+ const handlers = {};
+ const app = {
+ post(path, ...routeHandlers) {
+ handlers[path] = routeHandlers;
+ },
+ get() {}
+ };
+
+ const pool = {
+ async query(sql) {
+ if (sql.includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
+ return [[{ slug: 'alpha' }, { slug: 'beta' }]];
+ }
+ if (sql.includes('SELECT id, name, slug, playlist_id')) {
+ return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]];
+ }
+ if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?')) {
+ return [{ affectedRows: 1 }];
+ }
+ return [[]];
+ }
+ };
+
+ const calls = [];
+ const pages = { renderScreenFormPage() {}, renderScreenEditPage() {} };
+ const deps = {
+ pool,
+ common: {
+ slugify(value) { return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); },
+ uniqueScreenSlug: async () => 'beta',
+ fetchDuplicateName: async () => null,
+ fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }),
+ fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' })
+ },
+ pages,
+ getAuditUserId() { return 7; },
+ redirectAfterSave(req, res, url) {
+ calls.push({ kind: 'redirectAfterSave', url });
+ res.redirectedTo = url;
+ },
+ notifyPlayerScreens: async (slugs, payload) => {
+ calls.push({ kind: 'notifyPlayerScreens', slugs, payload });
+ return 1;
+ },
+ broadcastDashboardState: async () => {
+ calls.push({ kind: 'broadcastDashboardState' });
+ },
+ getScreenDeleteBlockMessage: async () => '',
+ getScreenConnections: async () => [],
+ forwardPlayerCommand: async (slug, payload) => {
+ calls.push({ kind: 'forwardPlayerCommand', slug, payload });
+ return { ok: true };
+ },
+ playerPublicBaseUrl: 'http://player.example',
+ requirePermission() {
+ return function (_req, _res, next) {
+ next();
+ };
+ }
+ };
+
+ registerManageRoutes(app, deps);
+
+ const routeHandlers = handlers['/screens/:id'];
+ assert.equal(Array.isArray(routeHandlers), true);
+
+ const req = {
+ params: { id: '42' },
+ body: { name: 'Updated Screen', slug: 'beta' }
+ };
+ const res = {
+ redirect(url) {
+ this.redirectedTo = url;
+ },
+ status() {
+ return this;
+ },
+ send() {
+ return this;
+ }
+ };
+
+ await routeHandlers[1](req, res, () => {});
+
+ assert.equal(res.redirectedTo, '/screens?edit=42');
+ assert.deepEqual(calls, [
+ { kind: 'forwardPlayerCommand', slug: 'alpha', payload: { command: 'redirect', url: 'http://player.local/screen/beta' } },
+ { kind: 'redirectAfterSave', url: '/screens?edit=42' }
+ ]);
+});
\ No newline at end of file