mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-05-30 17:35:37 -04:00
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>
187 lines
6.6 KiB
JavaScript
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);
|