Files
zoneminder/tests/js/encoder-templates.test.js
Isaac Connor c4073c964c feat: encoder parameter templates with editor + REST API closes #4778 closes #4802
Adds a curated, per-encoder parameter-template library to ZoneMinder:

- Monitor edit page: a new Template row above the EncoderParameters
  textarea offers per-encoder templates (Balanced / Archival / Low
  Power / Low CPU). Apply merges the template's params into the
  textarea, preserving user-only keys. Advisory lint flags option
  keys that aren't recognised for the selected encoder. Switching
  encoders offers a same-name template on the new encoder via a
  native confirm.

- Options page: a new Encoder Templates tab with full CRUD —
  list / edit / copy / delete — backed by a new CakePHP REST API
  at /api/encoder_templates.

- Storage: a new EncoderTemplates DB table seeded with 14 shipped
  defaults across libx264 / libx265 / h264_nvenc / hevc_nvenc /
  h264_vaapi / hevc_vaapi. The table is mutable; ZM upgrades do not
  re-seed user-edited rows.

- valid_keys (the lint allow-list) stays in PHP code as ffmpeg
  vocabulary, not user data.

- Default params explicitly include pix_fmt to avoid the yuvj420p
  HEVC HW-decode rejection issue we hit earlier.

No C++ change. The textarea content is parsed by the existing
av_dict_parse_string call in src/zm_videostore.cpp.

version.txt -> 1.39.6.

Specs: docs/superpowers/specs/2026-05-0{1,2}-*.md
Plans: docs/superpowers/plans/2026-05-0{1,2}-*.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:24:17 -04:00

187 lines
6.6 KiB
JavaScript

'use strict';
const assert = require('assert');
const path = require('path');
const ZM = require(path.join(__dirname,
'../../web/skins/classic/views/js/monitor-encoder-templates.js'));
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(' ok ' + name);
passed++;
} catch (e) {
console.error(' FAIL ' + name);
console.error(' ' + e.message);
failed++;
}
}
console.log('parseParams');
test('single key=value line', () => {
assert.deepStrictEqual(
ZM.parseParams('preset=fast'),
[{key: 'preset', value: 'fast'}]);
});
test('multiple lines', () => {
assert.deepStrictEqual(
ZM.parseParams('preset=fast\ncrf=23'),
[{key: 'preset', value: 'fast'}, {key: 'crf', value: '23'}]);
});
test('comma separator (av_dict_parse_string semantics)', () => {
assert.deepStrictEqual(
ZM.parseParams('preset=fast,crf=23'),
[{key: 'preset', value: 'fast'}, {key: 'crf', value: '23'}]);
});
test('blank lines are dropped', () => {
assert.deepStrictEqual(
ZM.parseParams('preset=fast\n\n\ncrf=23\n'),
[{key: 'preset', value: 'fast'}, {key: 'crf', value: '23'}]);
});
test('whitespace around key and value is trimmed', () => {
assert.deepStrictEqual(
ZM.parseParams(' preset = fast \n crf = 23 '),
[{key: 'preset', value: 'fast'}, {key: 'crf', value: '23'}]);
});
test('lines without = are dropped', () => {
assert.deepStrictEqual(
ZM.parseParams('preset=fast\njust_a_word\ncrf=23'),
[{key: 'preset', value: 'fast'}, {key: 'crf', value: '23'}]);
});
test('value containing = keeps the trailing equals', () => {
assert.deepStrictEqual(
ZM.parseParams('x264-params=keyint=30:bframes=0'),
[{key: 'x264-params', value: 'keyint=30:bframes=0'}]);
});
test('all-separator input returns empty array', () => {
assert.deepStrictEqual(ZM.parseParams(',,,'), []);
});
console.log('\nmergeParams');
test('overwrite existing key keeps position', () => {
const existing = [{key: 'preset', value: 'fast'}, {key: 'crf', value: '23'}];
const out = ZM.mergeParams(existing, {preset: 'slow'});
assert.deepStrictEqual(out, [{key: 'preset', value: 'slow'}, {key: 'crf', value: '23'}]);
});
test('append new key when not present', () => {
const existing = [{key: 'preset', value: 'fast'}];
const out = ZM.mergeParams(existing, {crf: '23'});
assert.deepStrictEqual(out, [{key: 'preset', value: 'fast'}, {key: 'crf', value: '23'}]);
});
test('preserve user-only keys', () => {
const existing = [{key: 'preset', value: 'fast'}, {key: 'custom_x', value: '1'}];
const out = ZM.mergeParams(existing, {crf: '23'});
assert.deepStrictEqual(out, [
{key: 'preset', value: 'fast'},
{key: 'custom_x', value: '1'},
{key: 'crf', value: '23'},
]);
});
test('idempotent: applying same template twice', () => {
const tmpl = {preset: 'slow', crf: '20'};
const once = ZM.mergeParams(ZM.parseParams(''), tmpl);
const twice = ZM.mergeParams(once, tmpl);
assert.deepStrictEqual(once, twice);
});
test('does not mutate input array', () => {
const existing = [{key: 'preset', value: 'fast'}];
const out = ZM.mergeParams(existing, {preset: 'slow'});
assert.strictEqual(existing[0].value, 'fast');
assert.strictEqual(out[0].value, 'slow');
});
test('handles prototype-named keys without crashing', () => {
assert.deepStrictEqual(
ZM.mergeParams([], {constructor: 'x', toString: 'y'}),
[{key: 'constructor', value: 'x'}, {key: 'toString', value: 'y'}]);
});
test('coerces numeric template values to strings', () => {
assert.deepStrictEqual(
ZM.mergeParams([], {crf: 23}),
[{key: 'crf', value: '23'}]);
});
console.log('\nserializeParams');
test('serializes one entry per line', () => {
const arr = [{key: 'preset', value: 'fast'}, {key: 'crf', value: '23'}];
assert.strictEqual(ZM.serializeParams(arr), 'preset=fast\ncrf=23');
});
test('empty array serializes to empty string', () => {
assert.strictEqual(ZM.serializeParams([]), '');
});
test('round-trip parse -> serialize is stable', () => {
const t = 'preset=fast\ncrf=23\ng=30';
assert.strictEqual(ZM.serializeParams(ZM.parseParams(t)), t);
});
console.log('\nlint');
const TEMPLATES_FIXTURE = {
libx264: {
valid_keys: ['preset', 'crf', 'g', 'profile', 'pix_fmt'],
templates: [],
},
h264_nvenc: {
valid_keys: ['preset', 'rc', 'cq', 'g', 'profile', 'pix_fmt'],
templates: [],
},
};
test('returns empty list when all keys are valid', () => {
const parsed = ZM.parseParams('preset=fast\ncrf=23');
assert.deepStrictEqual(ZM.lint(parsed, 'libx264', TEMPLATES_FIXTURE), []);
});
test('returns unknown keys', () => {
const parsed = ZM.parseParams('preset=fast\ncrf=23\ntune=zerolatency');
assert.deepStrictEqual(ZM.lint(parsed, 'libx264', TEMPLATES_FIXTURE), ['tune']);
});
test('reports each unknown key only once', () => {
const parsed = ZM.parseParams('foo=1\nfoo=2');
assert.deepStrictEqual(ZM.lint(parsed, 'libx264', TEMPLATES_FIXTURE), ['foo']);
});
test('returns [] for unknown encoder (no opinion)', () => {
const parsed = ZM.parseParams('anything=here');
assert.deepStrictEqual(ZM.lint(parsed, 'libsvtav1', TEMPLATES_FIXTURE), []);
});
test('returns [] when encoder is empty/auto', () => {
const parsed = ZM.parseParams('preset=fast');
assert.deepStrictEqual(ZM.lint(parsed, 'auto', TEMPLATES_FIXTURE), []);
assert.deepStrictEqual(ZM.lint(parsed, '', TEMPLATES_FIXTURE), []);
});
console.log('\nfindTemplateByName (cross-encoder match)');
const NAME_FIXTURE = {
libx264: {
valid_keys: ['preset'],
templates: [
{id: 1, name: 'Balanced', description: '', params: {preset: 'fast'}},
{id: 2, name: 'Archival', description: '', params: {preset: 'slow'}},
],
},
libx265: {
valid_keys: ['preset'],
templates: [
{id: 3, name: 'Balanced', description: '', params: {preset: 'fast'}},
{id: 4, name: 'archival', description: '', params: {preset: 'slow'}},
],
},
};
test('findTemplateByName: exact match', () => {
const t = ZM.findTemplateByName('libx265', 'Balanced', NAME_FIXTURE);
assert.strictEqual(t.id, 3);
});
test('findTemplateByName: case-insensitive match', () => {
const t = ZM.findTemplateByName('libx265', 'Archival', NAME_FIXTURE);
assert.strictEqual(t.id, 4);
});
test('findTemplateByName: no match returns null', () => {
const t = ZM.findTemplateByName('libx265', 'Low Power', NAME_FIXTURE);
assert.strictEqual(t, null);
});
test('findTemplateByName: unknown encoder returns null', () => {
const t = ZM.findTemplateByName('libsvtav1', 'Balanced', NAME_FIXTURE);
assert.strictEqual(t, null);
});
console.log('\n' + passed + ' passed, ' + failed + ' failed');
process.exit(failed ? 1 : 0);