Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ Kitty after you change it.

### Fixed

- **Captions clear in the quiet parts of a clip** (#228). A caption used to stay on
screen through a pause when the words either side of it were in the same caption. Now
a pause of more than 0.8 seconds ends the caption and the next word starts a new one,
and no single word keeps a caption up for more than 1.2 seconds. Captions you edited
yourself keep their times.
- **A clip's cuts and mutes stay on the same moments when you change its start or
end** in its details or with the AI chat. They used to move by as much as the start
did, so a cut word could come back and a moment you kept could be cut. A clip you make
Expand Down
1 change: 1 addition & 0 deletions docs/editor-suite/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ The 12 new fonts are made for languages written in the English alphabet. Some co
- If one of the new fonts is ever missing from your copy of Clips Kitty, that clip's captions are made in Arial instead, and the clip is still made. The video's progress notes say which font was missing, and say so too if all the new fonts are missing.
- If you render on a second PC (Settings → Advanced settings → Remote rendering), update Clips Kitty on that PC too. Until you do, that PC says it needs an update and takes no clips.
- Exports (Settings → Export) keep the captions exactly as they are in your clip.
- Captions clear in the quiet parts of a clip: a caption goes when its last word has been said, and after a pause of more than 0.8 seconds the next word starts a new caption, even if the one before had fewer words than you set. Captions you edited yourself keep their times.

## Creator notes for the AI, and free music and sound effects

Expand Down
515 changes: 515 additions & 0 deletions tests/test_caption_pauses.py

Large diffs are not rendered by default.

20 changes: 12 additions & 8 deletions tests/test_caption_speakers.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,11 @@ def test_nothing_heard_and_nothing_said_takes_a_stale_mark_off_a_saved_line():


def test_a_saved_line_goes_by_who_says_its_words_not_by_the_pause_inside_it():
""" "yeah" (fixed by hand) and "right exactly" (heard) are one caption
with two seconds of silence in the middle. By the clock the other speaker
has 1.1 s of its 2.8 s; by the words, all of it."""
""" "yeah" (fixed by hand) and "right exactly" (heard) are one saved
caption with two seconds of silence in the middle (a line made from the
transcript ends at such a pause, #228; one a person saved can still span
it). By the clock the other speaker has 1.1 s of its 2.8 s; by the words,
all of it."""
from video.captions import clip_words, paint_turns

words = [
Expand All @@ -261,12 +263,14 @@ def test_a_saved_line_goes_by_who_says_its_words_not_by_the_pause_inside_it():

built = build_caption_lines(segments, clip, 3, said)
assert [(line["text"], line.get("speaker")) for line in built] == [
("so then", None), ("yeah right exactly", 1), ("ok", None),
("so then", None), ("yeah", 1), ("right exactly", 1), ("ok", None),
]
# The same lines, saved (the editor sends them when a word is muted or
# retyped, without their marks), are marked the same again.
saved = [{k: v for k, v in line.items() if k != "speaker"} for line in built]
assert tag_lines(saved, said, clip_words(words, 0.0, 4.5)) == built
# The lines as they were made before captions ended at a pause, saved
# (the editor sends them when a word is muted or retyped, without their
# marks): the one with the pause in it is still marked whole.
saved = [{"start": 0.0, "end": 1.0, "text": "so then"}, {"start": 1.0, "end": 3.8, "text": "yeah right exactly"},
{"start": 4.0, "end": 4.5, "text": "ok"}]
assert tag_lines(saved, said, clip_words(words, 0.0, 4.5)) == [saved[0], {**saved[1], "speaker": 1}, saved[2]]
assert "speaker" not in tag_lines(saved, said)[1] # by the clock alone it was lost


Expand Down
11 changes: 6 additions & 5 deletions tests/test_second_speaker_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,24 +334,25 @@ def test_a_mark_left_on_a_saved_line_colours_nothing_when_nobody_else_is_heard(

def test_lines_the_editor_sends_with_a_fix_burn_as_the_editor_showed_them(pipeline, monkeypatch, tmp_path, heard):
"""A word muted in the same visit: the editor sends its lines, ending
where the speaker changes, and the fix beside them. A caption made of the
other speaker's words either side of a pause is still theirs."""
where the speaker changes, and the fix beside them. The other speaker's
words either side of a pause are two captions (#228), both still theirs."""
from video.captions import build_caption_lines, paint_turns

fix = [[5.0, 5.5, 1]] # w30, by hand; w34-w35 were heard
heard.answer = lambda: [[7.0, 8.0, 1]]
shown = build_caption_lines(_gapped(), CLIP, 3, paint_turns([[7.0, 8.0, 1]], fix))
assert [(line["text"], line.get("speaker")) for line in shown] == [
("w20 w21 w22", None), ("w23 w24 w25", None), ("w26 w27 w28", None), ("w29", None),
("w30 w34 w35", 1), ("w36 w37 w38", None), ("w39", None),
("w30", 1), ("w34 w35", 1), ("w36 w37 w38", None), ("w39", None),
]
sent = [{k: v for k, v in line.items() if k != "speaker"} for line in shown]

ass, _ = _render(pipeline, monkeypatch, tmp_path, ON, opts={"caption_lines": sent, "speaker_edits": fix},
segments=_gapped())

assert _events(ass)[4] == f"{{\\1c{SECOND}}}W30 W34 W35"
assert [e for e in _events(ass) if "\\1c" in e] == [f"{{\\1c{SECOND}}}W30 W34 W35"]
theirs = [f"{{\\1c{SECOND}}}W30", f"{{\\1c{SECOND}}}W34 W35"]
assert _events(ass)[4:6] == theirs
assert [e for e in _events(ass) if "\\1c" in e] == theirs


def test_a_hand_fix_marks_saved_lines_whole(pipeline, monkeypatch, tmp_path, heard):
Expand Down
26 changes: 22 additions & 4 deletions tests/test_ui_speaker_turns_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,22 @@ def _bursts() -> list[dict]:
return words


def _paused() -> list[dict]:
"""Quiet parts (#228): pauses just under, on and over the length that
ends a caption, up to a few seconds; words whose end the transcript
stretched across the silence after them, longer than a word counts as
spoken; a word of no length before a pause, and a very short one."""
words, t = [], 1.5
for i in range(260):
k = i % 13
length = (0.3, 0.42, 1.44, 0.25, 0.0, 0.36, 0.5, 2.1, 0.31, 0.04, 0.4, 1.2, 0.28)[k]
words.append({"start": round(t, 2), "end": round(t + length, 2), "word": f"p{i}"})
t += length + (0.8, 0.0, 0.0, 1.0, 0.9, 0.0, 0.81, 0.0, 0.0, 2.6, 0.0, 0.79, 0.0)[k]
return words


TRANSCRIPTS = {"steady": _steady(), "ragged": _ragged(), "talked_over": _talked_over(), "spoken": _spoken(),
"bursts": _bursts()}
"bursts": _bursts(), "paused": _paused()}
# Clip starts and ends in the video: on a word's edge, and cutting through one.
WINDOWS = [(10.0, 30.0), (249.67 - 240, 31.13), (12.21, 40.5), (3.0, 18.4), (20.36, 44.4)]
TURNS = [
Expand Down Expand Up @@ -222,7 +236,8 @@ def test_the_editor_reads_who_says_each_word_as_the_render_does(tmp_path):
def test_the_editor_breaks_and_colours_the_captions_as_the_render_does(tmp_path):
cases = _cases()

got = _ts(tmp_path, cases, "return m.groupWords(c.words, c.size, m.paintTurns(c.turns, c.edits))")
got = _ts(tmp_path, cases,
"return m.groupWords(c.words, c.size, m.paintTurns(c.turns, c.edits), c.end - c.start)")

for case, lines in zip(cases, got, strict=True):
assert lines == _lines(case, paint_turns(case["turns"], case["edits"])), case
Expand Down Expand Up @@ -258,7 +273,8 @@ def test_muting_a_word_does_not_change_whose_colour_a_caption_is(tmp_path):
will send through tagLines, the test above, so it still shows what burns."""
cases = [c for c in _cases() if c["transcript"] not in ("talked_over", "bursts")]

got = _ts(tmp_path, cases, "return m.groupWords(c.words, c.size, m.paintTurns(c.turns, c.edits))")
got = _ts(tmp_path, cases,
"return m.groupWords(c.words, c.size, m.paintTurns(c.turns, c.edits), c.end - c.start)")

for case, shown in zip(cases, got, strict=True):
sent = [{k: v for k, v in line.items() if k != "speaker"} for line in shown]
Expand Down Expand Up @@ -504,7 +520,9 @@ def test_each_word_is_in_the_line_the_editor_made_for_it(tmp_path):

got = _ts(tmp_path, cases, """
const turns = m.paintTurns(c.turns, c.edits);
return { lines: m.groupWords(c.words, c.size, turns), held: m.groupOfWords(c.words, c.size, turns) };
const length = c.end - c.start;
return { lines: m.groupWords(c.words, c.size, turns, length),
held: m.groupOfWords(c.words, c.size, turns, length) };
""")

left_out = 0
Expand Down
4 changes: 2 additions & 2 deletions ui/src/renderer/src/components/TimelineEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -922,12 +922,12 @@ export default function TimelineEditor({
base = given
? tagLines(given, saidTurns, words)
: words.length > 0 && fixing.covered
? groupWords(words, n, saidTurns)
? groupWords(words, n, saidTurns, duration)
: tagLines(base, saidTurns, words)
} else if (words.length > 0 && n !== storedStyle.words_per_caption) {
// Regroup from the transcript when words-per-caption changed, the
// same way the render does (captionBase kept the old grouping).
base = groupWords(words, n)
base = groupWords(words, n, [], duration)
}
captions = { lines: applyTextEdits(base, wordEdits, edit.muted_words), style: captionStyle }
}
Expand Down
Loading
Loading