mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
Client-side GL Transitions v1 across all three players with never-blank degradation: shader lib + generated manifest + real-WebGL CI, transition-as-widget normalization, persistent WebGL/GLES2 compositors (web/Tizen/native Android), image↔video wipes, SSRF-hardened media proxy, decode-gated image preload, dashboard picker. Fixes the playlist change-fingerprint that dropped transition edits. Alpha + on-device soak validated.
89 lines
4.9 KiB
JavaScript
89 lines
4.9 KiB
JavaScript
'use strict';
|
|
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const { resolveTransitionConfig, normalizeTransitions } = require('../lib/transition-config');
|
|
|
|
const T = (cfg) => ({ widget_id: 'w', widget_type: 'transition', widget_config: JSON.stringify(cfg) });
|
|
const IMG = (id) => ({ content_id: id, mime_type: 'image/png' });
|
|
|
|
test('resolveTransitionConfig: valid shader resolves + clamps params/duration', () => {
|
|
const r = resolveTransitionConfig({ shader: 'CRTCollapse', params: { lineHold: 99, flashGain: -5 }, durationMs: 800, scope: 'next' });
|
|
assert.equal(r.effects.length, 1);
|
|
assert.equal(r.effects[0].shader, 'CRTCollapse');
|
|
assert.equal(r.durationMs, 800);
|
|
assert.equal(r.scope, 'next');
|
|
assert.equal(r.effects[0].params.lineHold, 0.45, 'over-range param clamped to shader max');
|
|
assert.equal(r.effects[0].params.flashGain, 0, 'under-range param clamped to shader min');
|
|
});
|
|
|
|
test('resolveTransitionConfig: multiple shaders -> one effect each, per-shader params via map', () => {
|
|
const r = resolveTransitionConfig({ shaders: ['CRTCollapse', 'Etch'], params: { CRTCollapse: { lineHold: 0.2 } }, durationMs: 500 });
|
|
assert.deepEqual(r.effects.map((e) => e.shader), ['CRTCollapse', 'Etch']);
|
|
assert.equal(r.effects[0].params.lineHold, 0.2, 'per-shader params applied from the map');
|
|
});
|
|
|
|
test('resolveTransitionConfig: unknown ids dropped from the set, dups collapsed, all-unknown -> null', () => {
|
|
const r = resolveTransitionConfig({ shaders: ['CRTCollapse', 'Ghost', 'CRTCollapse', 'Etch'] });
|
|
assert.deepEqual(r.effects.map((e) => e.shader), ['CRTCollapse', 'Etch']);
|
|
assert.equal(resolveTransitionConfig({ shaders: [] }), null);
|
|
assert.equal(resolveTransitionConfig({ shaders: ['Ghost'] }), null);
|
|
});
|
|
|
|
test('resolveTransitionConfig: unknown shader -> null (hard cut, never black)', () => {
|
|
assert.equal(resolveTransitionConfig({ shader: 'NopeShader' }), null);
|
|
assert.equal(resolveTransitionConfig({ shader: '' }), null);
|
|
assert.equal(resolveTransitionConfig('not json'), null);
|
|
});
|
|
|
|
test('resolveTransitionConfig: duration bounded, scope defaults to all (one widget covers the playlist)', () => {
|
|
assert.equal(resolveTransitionConfig({ shader: 'Etch', durationMs: 999999 }).durationMs, 3000);
|
|
assert.equal(resolveTransitionConfig({ shader: 'Etch', durationMs: 1 }).durationMs, 150);
|
|
assert.equal(resolveTransitionConfig({ shader: 'Etch' }).durationMs, 800, 'missing duration -> default');
|
|
assert.equal(resolveTransitionConfig({ shader: 'Etch' }).scope, 'all', 'one widget covers the whole playlist by default');
|
|
assert.equal(resolveTransitionConfig({ shader: 'Etch', scope: 'all' }).scope, 'all');
|
|
assert.equal(resolveTransitionConfig({ shader: 'Etch', scope: 'next' }).scope, 'next', 'explicit next still honored');
|
|
});
|
|
|
|
test('normalizeTransitions: scope:next attaches to the FOLLOWING item, widget dropped', () => {
|
|
const out = normalizeTransitions([IMG('a'), T({ shader: 'CRTCollapse', scope: 'next' }), IMG('b'), IMG('c')]);
|
|
assert.equal(out.length, 3, 'transition widget removed from visible list');
|
|
assert.deepEqual(out.map((i) => i.content_id), ['a', 'b', 'c']);
|
|
assert.equal(out[0].transition, undefined);
|
|
assert.equal(out[1].transition.effects[0].shader, 'CRTCollapse', 'plays INTO b');
|
|
assert.equal(out[2].transition, undefined);
|
|
});
|
|
|
|
test('normalizeTransitions: scope:all is a playlist default, scope:next overrides it', () => {
|
|
const out = normalizeTransitions([
|
|
T({ shader: 'Etch', scope: 'all' }),
|
|
IMG('a'), IMG('b'),
|
|
T({ shader: 'CRTCollapse', scope: 'next' }), IMG('c'),
|
|
]);
|
|
assert.equal(out.length, 3);
|
|
assert.equal(out[0].transition.effects[0].shader, 'Etch', 'default applies to a');
|
|
assert.equal(out[1].transition.effects[0].shader, 'Etch', 'default applies to b');
|
|
assert.equal(out[2].transition.effects[0].shader, 'CRTCollapse', 'override wins for c');
|
|
});
|
|
|
|
test('normalizeTransitions: a trailing scope:next wraps onto the first item (loop)', () => {
|
|
const out = normalizeTransitions([IMG('a'), IMG('b'), T({ shader: 'ReelChange', scope: 'next' })]);
|
|
assert.equal(out.length, 2);
|
|
assert.equal(out[0].transition.effects[0].shader, 'ReelChange', 'last->first advance');
|
|
assert.equal(out[1].transition, undefined);
|
|
});
|
|
|
|
test('normalizeTransitions: unknown-shader transition widget is dropped, no transition attached', () => {
|
|
const out = normalizeTransitions([IMG('a'), T({ shader: 'Ghost' }), IMG('b')]);
|
|
assert.equal(out.length, 2);
|
|
assert.equal(out[1].transition, undefined, 'invalid config -> hard cut, not a black frame');
|
|
});
|
|
|
|
test('normalizeTransitions: non-transition widgets pass through untouched', () => {
|
|
const clock = { widget_id: 'c1', widget_type: 'clock' };
|
|
const out = normalizeTransitions([IMG('a'), clock, T({ shader: 'Etch', scope: 'next' }), IMG('b')]);
|
|
assert.equal(out.length, 3);
|
|
assert.equal(out[1].widget_type, 'clock', 'clock widget stays visible');
|
|
assert.equal(out[1].transition, undefined);
|
|
assert.equal(out[2].transition.effects[0].shader, 'Etch');
|
|
});
|