Skip to content

Commit 51f0382

Browse files
authored
Merge pull request #17 from Nedi11/fix/censor-absolute-edges
Censor: hand-set edges are absolute; defaults only place untouched edges
2 parents 8bccbe8 + 1405de3 commit 51f0382

7 files changed

Lines changed: 188 additions & 107 deletions

File tree

‎dashboard/src/components/ClipEditor.jsx‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import { getApiUrl, withParam } from '../config';
88
import { apiFetch, apiJson, QuotaError, openQuotaWall } from '../lib/api';
99
import { useAuth } from '../contexts/AuthContext';
10-
import { censorSections, adjust, moveEdge, resetWord, parseWords, PAD_BEFORE, PAD_AFTER, STEPS } from '../lib/censorSections';
10+
import { censorSections, setSkip, moveEdge, resetWord, parseWords, PAD_BEFORE, PAD_AFTER, STEPS } from '../lib/censorSections';
1111

1212
// Full-screen clip editor: shows WHICH source segments a clip was cut from,
1313
// lets the user trim/extend/split/reorder them (word-snapped), and re-renders
@@ -1549,10 +1549,10 @@ export default function ClipEditor({ jobId, clipIndex, clipTitle, onClose, onRer
15491549
</label>
15501550
<p className="text-xs text-muted">Exact words, ignoring case and punctuation. Add variants or words in your language. Uses transcript timing; review the rendered result.</p>
15511551
<p className="text-xs text-muted">Bleep replaces caption text with [bleep]. Cut shortens the video and removes those caption words. Applied when you render.</p>
1552-
<label className="block readout">Start earlier by {censorPadBefore.toFixed(2)}s
1552+
<label className="block readout">Default: start earlier by {censorPadBefore.toFixed(2)}s
15531553
<input aria-label="Censor lead" className="w-full accent-[var(--color-accent)]" type="range" min="0" max="0.5" step="0.01" value={censorPadBefore} onChange={e => setCensorPadBefore(Number(e.target.value))} />
15541554
</label>
1555-
<label className="block readout">End later by {censorPadAfter.toFixed(2)}s
1555+
<label className="block readout">Default: end later by {censorPadAfter.toFixed(2)}s
15561556
<input aria-label="Censor tail" className="w-full accent-[var(--color-accent)]" type="range" min="0" max="0.5" step="0.01" value={censorPadAfter} onChange={e => setCensorPadAfter(Number(e.target.value))} />
15571557
</label>
15581558
<div>
@@ -1571,14 +1571,19 @@ export default function ClipEditor({ jobId, clipIndex, clipTitle, onClose, onRer
15711571
</span>
15721572
<span className="flex items-center gap-1">
15731573
{sourceAvailable && <button type="button" className="btn-ghost !px-1.5 !py-0.5" onClick={() => playSection(c)} title="Play this section on the source monitor">play</button>}
1574-
<label className="flex items-center gap-1 text-muted"><input type="checkbox" checked={!c.skip} onChange={e => setCensorAdjustments((a) => adjust(a, c.key, 'skip', !e.target.checked))} aria-label={`Censor ${c.word} at ${fmtClock(c.clipStart)}`} />on</label>
1574+
<label className="flex items-center gap-1 text-muted"><input type="checkbox" checked={!c.skip} onChange={e => setCensorAdjustments((a) => setSkip(a, c.key, !e.target.checked))} aria-label={`Censor ${c.word} at ${fmtClock(c.clipStart)}`} />on</label>
15751575
</span>
15761576
</div>
15771577
{!c.skip && (
15781578
<div className="mt-1 space-y-1 text-muted">
15791579
<p>
15801580
removes <span className="font-mono text-ink2">{c.crossed ? '—' : `${fmtClock(c.clipStart)} – ${fmtClock(c.clipEnd)}`}</span>
1581-
{(c.before || c.after) ? (
1581+
{(c.manualStart || c.manualEnd) && (
1582+
<span className="ml-1" title="Hand-set edges keep their time when the start-earlier / end-later presets change">
1583+
({c.manualStart && c.manualEnd ? 'both edges' : c.manualStart ? 'start' : 'end'} set by hand)
1584+
</span>
1585+
)}
1586+
{(c.manualStart || c.manualEnd) ? (
15821587
<button type="button" className="underline underline-offset-2 ml-2 hover:text-ink" onClick={() => setCensorAdjustments((a) => resetWord(a, c.key))}>reset</button>
15831588
) : null}
15841589
</p>
@@ -1610,6 +1615,7 @@ export default function ClipEditor({ jobId, clipIndex, clipTitle, onClose, onRer
16101615
))}
16111616
</div>
16121617
<p className="text-[11px] text-muted mt-1">◀ earlier · ▶ later, one step per click. Start/End move one edge, Move shifts the whole section. Sections can be as short as 0.01s. Times are on the edited clip.</p>
1618+
<p className="text-[11px] text-muted">Start earlier / End later only place edges you haven't moved. Once you move an edge it keeps its time; reset hands it back to them.</p>
16131619
</div>
16141620
</>}
16151621
</div>

‎dashboard/src/lib/censorSections.js‎

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,50 +38,63 @@ export function censorSections(words, terms, segments, padBefore, padAfter, adju
3838
const home = segments[idx];
3939
const key = occurrenceKey(w);
4040
const adj = adjustments[key] || {};
41-
const rawStart = w.s - clamp(padBefore, 0, 1) - clamp(adj.before, -MAX_NUDGE, MAX_NUDGE);
42-
const rawEnd = w.e + clamp(padAfter, 0, 1) + clamp(adj.after, -MAX_NUDGE, MAX_NUDGE);
41+
// Presets place an edge until it is set by hand; a hand-set edge is an
42+
// absolute source time the presets no longer move (word_censor.sections).
43+
const presetStart = w.s - clamp(padBefore, 0, 1) - clamp(adj.before, -MAX_NUDGE, MAX_NUDGE);
44+
const presetEnd = w.e + clamp(padAfter, 0, 1) + clamp(adj.after, -MAX_NUDGE, MAX_NUDGE);
45+
const manualStart = adj.start !== undefined && adj.start !== null;
46+
const manualEnd = adj.end !== undefined && adj.end !== null;
47+
const rawStart = manualStart ? Number(adj.start) : presetStart;
48+
const rawEnd = manualEnd ? Number(adj.end) : presetEnd;
4349
const start = Math.max(home.start, rawStart);
4450
const end = Math.min(home.end, rawEnd);
4551
out.push({
4652
key, word: w.w, s: w.s, e: w.e, start: r3(start), end: r3(end),
53+
rawStart: r3(rawStart), rawEnd: r3(rawEnd), manualStart, manualEnd,
4754
clipStart: r3(offsets[idx] + start - home.start),
4855
clipEnd: r3(offsets[idx] + end - home.start),
49-
// crossed: the nudges put the end before the start (the server
50-
// refuses it); tooShort: only the segment edge leaves too little.
56+
// crossed: the edges are the wrong way round (the server refuses
57+
// it); tooShort: only the segment edge leaves too little.
5158
skip: !!adj.skip, crossed: rawEnd - rawStart < MIN_SECTION,
5259
tooShort: rawEnd - rawStart >= MIN_SECTION && end - start < MIN_SECTION,
5360
length: r3(rawEnd - rawStart),
54-
before: clamp(adj.before, -MAX_NUDGE, MAX_NUDGE), after: clamp(adj.after, -MAX_NUDGE, MAX_NUDGE),
5561
});
5662
}
5763
return out.sort((a, b) => a.clipStart - b.clipStart);
5864
}
5965

60-
// Adjustment map with one field changed; entries back at zero are dropped.
61-
export function adjust(adjustments, key, field, value) {
62-
const entry = { before: 0, after: 0, skip: false, ...(adjustments[key] || {}), [field]: value };
63-
if (field !== 'skip') entry[field] = r3(clamp(value, -MAX_NUDGE, MAX_NUDGE));
66+
// Adjustment map with one word's entry replaced; an empty entry is dropped.
67+
function withEntry(adjustments, key, entry) {
6468
const next = { ...adjustments };
65-
if (!entry.before && !entry.after && !entry.skip) delete next[key];
66-
else next[key] = entry;
69+
const clean = Object.fromEntries(Object.entries(entry).filter(([k, v]) =>
70+
v !== undefined && v !== null && !(k === 'skip' && !v) && !((k === 'before' || k === 'after') && !v)));
71+
if (Object.keys(clean).length) next[key] = clean;
72+
else delete next[key];
6773
return next;
6874
}
6975

76+
export function setSkip(adjustments, key, skip) {
77+
return withEntry(adjustments, key, { ...(adjustments[key] || {}), skip });
78+
}
79+
7080
// Edge moves for one section. 'start'/'end' move that edge by delta seconds
71-
// (negative = earlier); 'move' shifts both. Only SHRINKING below
72-
// MIN_EDIT_LENGTH is refused (null); widening a too-short section is always
73-
// allowed, so a crossed section can be pulled back open.
81+
// (negative = earlier); 'move' shifts both. The moved edge becomes hand-set:
82+
// an absolute time the start-earlier / end-later presets no longer change.
83+
// Only SHRINKING below MIN_EDIT_LENGTH is refused (null); widening a
84+
// too-short section is always allowed, so a crossed section can be reopened.
7485
export function moveEdge(adjustments, sec, edge, delta) {
75-
const cur = adjustments[sec.key] || {};
76-
const before = (cur.before || 0) - (edge === 'end' ? 0 : delta);
77-
const after = (cur.after || 0) + (edge === 'start' ? 0 : delta);
78-
const length = sec.length + (sec.before - before) * -1 + (after - sec.after);
86+
const start = edge === 'end' ? sec.rawStart : r3(sec.rawStart + delta);
87+
const end = edge === 'start' ? sec.rawEnd : r3(sec.rawEnd + delta);
88+
const length = end - start;
7989
if (edge !== 'move' && length < sec.length && length < MIN_EDIT_LENGTH - 1e-9) return null;
80-
if (Math.abs(before) > MAX_NUDGE + 1e-9 || Math.abs(after) > MAX_NUDGE + 1e-9) return null;
81-
return adjust(adjust(adjustments, sec.key, 'before', before), sec.key, 'after', after);
90+
if (start < 0) return null;
91+
const entry = { ...(adjustments[sec.key] || {}) };
92+
if (edge !== 'end') { entry.start = start; delete entry.before; }
93+
if (edge !== 'start') { entry.end = end; delete entry.after; }
94+
return withEntry(adjustments, sec.key, entry);
8295
}
8396

84-
// Back to the padded default for this word (keeps a skip).
97+
// Back to the presets for this word (keeps a skip).
8598
export function resetWord(adjustments, key) {
86-
return adjust(adjust(adjustments, key, 'before', 0), key, 'after', 0);
99+
return withEntry(adjustments, key, { skip: !!(adjustments[key] || {}).skip });
87100
}

‎dashboard/tests/censorSections.test.cjs‎

Lines changed: 69 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -6,94 +6,105 @@ const vm = require('node:vm');
66
const result = buildSync({entryPoints:[require('node:path').join(__dirname,'../src/lib/censorSections.js')],bundle:true,write:false,format:'cjs'});
77
const sandbox = {module:{exports:{}}};
88
vm.runInNewContext(result.outputFiles[0].text, sandbox);
9-
const {censorSections, adjust, moveEdge, resetWord, normalizeWord, parseWords} = sandbox.module.exports;
9+
const {censorSections, setSkip, moveEdge, resetWord, normalizeWord, parseWords} = sandbox.module.exports;
1010

1111
// Objects come from the vm sandbox's realm, so compare as JSON.
1212
const same = (a, b) => assert.equal(JSON.stringify(a), JSON.stringify(b));
13+
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`);
1314
const WORDS = [{w:'hello',s:0,e:1},{w:'Shit!',s:1,e:1.5},{w:'ship',s:2,e:3}];
15+
const SEG = [{start:0,end:3}];
16+
const one = (pb, pa, adj, segs = SEG, words = WORDS) => censorSections(words, ['shit'], segs, pb, pa, adj)[0];
1417

1518
test('default padding starts the section before the transcript timing', () => {
16-
const [sec] = censorSections(WORDS, ['shit'], [{start:0,end:3}], 0.15, 0.05, {});
17-
assert.deepEqual([sec.key, sec.start, sec.end], ['1.000', 0.85, 1.55]);
19+
const sec = one(0.15, 0.05, {});
20+
same([sec.key, sec.start, sec.end, sec.manualStart, sec.manualEnd], ['1.000', 0.85, 1.55, false, false]);
1821
});
1922

20-
test('nudges, clamping and skip mirror the server', () => {
21-
let adj = adjust({}, '1.000', 'before', 0.2);
22-
adj = adjust(adj, '1.000', 'after', -0.1);
23-
const [sec] = censorSections(WORDS, ['shit'], [{start:0,end:3}], 0.1, 0, adj);
24-
assert.deepEqual([sec.start, sec.end], [0.7, 1.4]);
25-
assert.equal(censorSections(WORDS, ['shit'], [{start:0.9,end:3}], 1, 0.05, {})[0].start, 0.9);
26-
const skipped = adjust({}, '1.000', 'skip', true);
27-
assert.equal(censorSections(WORDS, ['shit'], [{start:0,end:3}], 0.15, 0.05, skipped)[0].skip, true);
28-
// Back to zero drops the entry, so an untouched word sends nothing.
29-
same(adjust(adj, '1.000', 'before', 0), {'1.000': {before: 0, after: -0.1, skip: false}});
30-
same(adjust(adjust({}, '1.000', 'after', 0.05), '1.000', 'after', 0), {});
23+
test('hand-set edges keep their time when the presets change; the other edge follows', () => {
24+
let sec = one(0.15, 0.05, {});
25+
const adj = moveEdge({}, sec, 'start', -0.1); // start 0.85 -> 0.75, by hand
26+
same(adj, {'1.000': {start: 0.75}});
27+
sec = one(0.5, 0.3, adj); // presets raised afterwards
28+
same([sec.start, sec.manualStart, sec.end, sec.manualEnd], [0.75, true, 1.8, false]);
29+
sec = one(0, 0, adj); // and lowered
30+
same([sec.start, sec.end], [0.75, 1.5]);
31+
// Moving the whole section fixes both edges.
32+
const moved = moveEdge(adj, one(0, 0, adj), 'move', 0.05);
33+
same(moved, {'1.000': {start: 0.8, end: 1.55}});
34+
same([one(0.4, 0.4, moved).start, one(0.4, 0.4, moved).end], [0.8, 1.55]);
3135
});
3236

33-
test('clip timeline follows segment order', () => {
34-
const two = [{w:'shit',s:1,e:1.5},{w:'shit',s:5,e:5.5}];
35-
const secs = censorSections(two, ['shit'], [{start:4,end:6},{start:0,end:2}], 0, 0, {});
36-
same(secs.map((s) => [s.key, s.clipStart]), [['5.000', 1], ['1.000', 3]]);
37+
test('reset and skip', () => {
38+
const adj = setSkip(moveEdge({}, one(0.15, 0.05, {}), 'end', 0.25), '1.000', true);
39+
same(adj, {'1.000': {end: 1.8, skip: true}});
40+
same(resetWord(adj, '1.000'), {'1.000': {skip: true}});
41+
same(resetWord(setSkip(adj, '1.000', false), '1.000'), {});
42+
assert.equal(one(0.15, 0.05, adj).skip, true);
3743
});
3844

39-
test('word normalisation matches word_censor.normalize', () => {
40-
assert.equal(normalizeWord("'Scheiße!'"), 'scheiße');
41-
assert.equal(normalizeWord("Don't,"), "don't");
42-
same(parseWords(' fuck, ,shit '), ['fuck', 'shit']);
45+
test('legacy relative offsets saved before hand-set edges are still read', () => {
46+
const sec = one(0.1, 0, {'1.000': {before: 0.2, after: -0.1}});
47+
same([sec.start, sec.end], [0.7, 1.4]);
48+
near(one(1, 0.05, {}, [{start:0.9,end:3}]).start, 0.9); // clamped to the segment
4349
});
4450

45-
test('the reported crossed case is flagged, and reset or widening repairs it', () => {
46-
// "fuck" 238.08-238.20, lead 0.5, start 1.0 s later, end 0.85 s earlier.
47-
const word = [{w:'fuck', s:238.08, e:238.2}];
51+
test('the reported crossed case is flagged, and widening or reset repairs it', () => {
52+
// "fuck" 238.08-238.20, lead 0.5, legacy start 1.0 s later, end 0.85 s earlier.
53+
const word = [{w:'shit', s:238.08, e:238.2}];
4854
const seg = [{start:157.74, end:239.369}];
49-
const broken = {'238.080': {before:-1, after:-0.85, skip:false}};
50-
const [sec] = censorSections(word, ['fuck'], seg, 0.5, 0.05, broken);
55+
const broken = {'238.080': {before:-1, after:-0.85}};
56+
const sec = one(0.5, 0.05, broken, seg, word);
5157
assert.equal(sec.crossed, true);
52-
// Shrinking further is refused; widening is allowed.
53-
assert.equal(moveEdge(broken, sec, 'start', 0.05), null);
54-
assert.notEqual(moveEdge(broken, sec, 'start', -0.05), null);
55-
const [fixed] = censorSections(word, ['fuck'], seg, 0.5, 0.05, resetWord(broken, '238.080'));
56-
assert.deepEqual([fixed.crossed, fixed.start, fixed.end], [false, 237.58, 238.25]);
58+
assert.equal(moveEdge(broken, sec, 'start', 0.05), null); // shrinking further: refused
59+
const widened = moveEdge(broken, sec, 'start', -1.3); // start earlier, by hand
60+
same(widened, {'238.080': {after: -0.85, start: 237.28}});
61+
assert.equal(one(0.5, 0.05, widened, seg, word).crossed, false);
62+
const fixed = one(0.5, 0.05, resetWord(broken, '238.080'), seg, word);
63+
same([fixed.crossed, fixed.start, fixed.end], [false, 237.58, 238.25]);
5764
});
5865

59-
test('edge moves stop at the minimum length; move shifts both edges', () => {
60-
const [sec] = censorSections(WORDS, ['shit'], [{start:0,end:3}], 0, 0, {}); // 0.5 s long, 0.05 s steps
66+
test('sections shrink to 0.01 s; 0.05 s steps stop before reaching zero', () => {
67+
let sec = one(0, 0, {}); // 0.5 s long
6168
let adj = {};
62-
let current = sec;
6369
for (let i = 0; i < 20; i++) {
64-
const next = moveEdge(adj, current, 'start', 0.05);
70+
const next = moveEdge(adj, sec, 'start', 0.05);
6571
if (!next) break;
6672
adj = next;
67-
[current] = censorSections(WORDS, ['shit'], [{start:0,end:3}], 0, 0, adj);
73+
sec = one(0, 0, adj);
6874
}
69-
// 0.05 s steps stop at 0.05 s (one more would reach zero); 0.01 s steps go to 0.01 s.
70-
assert.ok(Math.abs(current.end - current.start - 0.05) < 1e-9);
71-
const fine = moveEdge(adj, current, 'end', -0.01);
72-
[current] = censorSections(WORDS, ['shit'], [{start:0,end:3}], 0, 0, fine);
73-
assert.ok(Math.abs(current.end - current.start - 0.04) < 1e-9);
74-
const [moved] = censorSections(WORDS, ['shit'], [{start:0,end:3}], 0, 0, moveEdge({}, sec, 'move', 0.05));
75-
assert.deepEqual([moved.start, moved.end], [1.05, 1.55]);
76-
});
77-
78-
test('per-word moves go past one second', () => {
79-
const [sec] = censorSections(WORDS, ['shit'], [{start:0,end:3}], 0, 0, {});
80-
let adj = {};
81-
let cur = sec;
82-
for (let i = 0; i < 30; i++) { adj = moveEdge(adj, cur, 'end', 0.05); [cur] = censorSections(WORDS, ['shit'], [{start:0,end:3}], 0, 0, adj); }
83-
assert.equal(adj['1.000'].after, 1.5);
84-
assert.equal(cur.end, 3); // clamped by the segment, not by a nudge limit
75+
near(sec.end - sec.start, 0.05);
76+
sec = one(0, 0, moveEdge(adj, sec, 'end', -0.04));
77+
near(sec.end - sec.start, 0.01);
78+
assert.equal(sec.crossed, false);
8579
});
8680

8781
test('a 0.07 s section shrinks to 0.02 s and is still applied', () => {
8882
const word = [{w:'shit', s:80.9, e:80.95}];
8983
const seg = [{start:0, end:100}];
90-
let [sec] = censorSections(word, ['shit'], seg, 0.01, 0.01, {}); // 80.89-80.96
91-
assert.deepEqual([sec.start, sec.end], [80.89, 80.96]);
84+
let sec = one(0.01, 0.01, {}, seg, word);
85+
same([sec.start, sec.end], [80.89, 80.96]);
9286
let adj = {};
9387
for (const [edge, d] of [['start', 0.01], ['start', 0.01], ['end', -0.01], ['end', -0.01], ['end', -0.01]]) {
9488
adj = moveEdge(adj, sec, edge, d);
95-
[sec] = censorSections(word, ['shit'], seg, 0.01, 0.01, adj);
89+
sec = one(0.01, 0.01, adj, seg, word);
9690
}
97-
assert.deepEqual([sec.start, sec.end, sec.crossed, sec.tooShort], [80.91, 80.93, false, false]);
98-
assert.equal(moveEdge(adj, sec, 'end', -0.02), null); // would leave nothing
91+
same([sec.start, sec.end, sec.crossed, sec.tooShort], [80.91, 80.93, false, false]);
92+
assert.equal(moveEdge(adj, sec, 'end', -0.02), null); // would leave nothing
93+
});
94+
95+
test('hand-set edges go well past one second', () => {
96+
const sec = one(0, 0, moveEdge({}, one(0, 0, {}), 'end', 1.5));
97+
same([sec.rawEnd, sec.end], [3, 3]); // clamped by the segment only
98+
});
99+
100+
test('clip timeline follows segment order', () => {
101+
const two = [{w:'shit',s:1,e:1.5},{w:'shit',s:5,e:5.5}];
102+
const secs = censorSections(two, ['shit'], [{start:4,end:6},{start:0,end:2}], 0, 0, {});
103+
same(secs.map((s) => [s.key, s.clipStart]), [['5.000', 1], ['1.000', 3]]);
104+
});
105+
106+
test('word normalisation matches word_censor.normalize', () => {
107+
assert.equal(normalizeWord("'Scheiße!'"), 'scheiße');
108+
assert.equal(normalizeWord("Don't,"), "don't");
109+
same(parseWords(' fuck, ,shit '), ['fuck', 'shit']);
99110
});

0 commit comments

Comments
 (0)