Skip to content

Commit f3aea0a

Browse files
ColinGPT9claude
andauthored
Fix cloud AI keys, locked clips, captions off, and remembered Generate settings (#139)
Fixes #135, #137, #128 and #138; the README star goal is 125. #136 (YouTube cookies) is held back on its own branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ff77796 commit f3aea0a

17 files changed

Lines changed: 427 additions & 46 deletions

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,20 @@ were often broken in a way that only showed up on somebody else's machine.
186186

187187
### Fixed
188188

189+
- **Cloud AI keys save again.** In 2.0.0, saving a key for OpenRouter, Gemini, OpenAI or
190+
Anthropic failed with an error, because part of the cloud AI support was left out of
191+
the installed app. (#135)
192+
- **Adding a watermark to a clip that is open in the editor works.** It failed with
193+
"Access is denied", and **Update preview** with "file in use", because the clip or its
194+
last preview was still playing. (#137)
195+
- **A clip made with captions off stays without them when you edit it.** Its first edit
196+
used to burn the captions back in. The editor's **Captions** tab also has a
197+
**Captions** on/off switch for each clip now. (#128)
198+
- **The Generate row remembers the rest of its settings.** The caption style, the
199+
**Longform output** you picked from the list, the **Pipeline** and the **Rate &
200+
understand** plugins now carry over to the next video and survive a restart, like the
201+
other switches already did. (#138)
202+
189203
- **Processing a video again keeps your edits.** **Process again** made every clip it
190204
found again from the start: cuts, mutes, a layout, captions you fixed, colours,
191205
branding and title-card words you had applied in the editor stayed in the editor but

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515
[![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](CONTRIBUTING.md)
1616
[![Docker](https://img.shields.io/badge/docker-compose%20up-2496ED?logo=docker&logoColor=white)](docs/DOCKER.md)
1717

18-
### Star and share Clips Kitty ⭐ Goal: 100
18+
### Star and share Clips Kitty ⭐ Goal: 125
1919

20-
[![Stars so far](https://img.shields.io/github/stars/ColinGPT9/clips-studio?label=stars%20so%20far&style=for-the-badge&color=0969da)](https://github.com/ColinGPT9/clips-studio/stargazers) **of 100**
20+
[![Stars so far](https://img.shields.io/github/stars/ColinGPT9/clips-studio?label=stars%20so%20far&style=for-the-badge&color=0969da)](https://github.com/ColinGPT9/clips-studio/stargazers) **of 125**
2121

2222
**Please star the repo and share it.** It costs you nothing, and there is no
2323
marketing budget behind this, so word of mouth is the only way the next small

‎clips-studio.spec‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ hiddenimports += collect_submodules("sports")
5656
# Plugins (plugins/): imported inside functions only when a job names a
5757
# pipeline plugin, so name them all rather than rely on the analyser.
5858
hiddenimports += collect_submodules("plugins")
59+
# Cloud AI adapters (llm/providers/adapters/): adapter_for imports each by the
60+
# name its provider spec gives, which the analyser can't follow. 2.0.0 shipped
61+
# without them and saving any cloud key failed with a 500 (#135).
62+
hiddenimports += collect_submodules("llm.providers.adapters")
5963
hiddenimports += [
6064
"uvicorn.logging",
6165
"uvicorn.loops.auto",

‎core/pipeline.py‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -856,13 +856,23 @@ def _free_end(db: StateDB, video_id: str, start: float, end: float, own: int | N
856856
# What the job decides for every clip, whatever a clip saved: Longform's
857857
# profile, Podcast, Vertical Live, the sport, and who is heard talking,
858858
# which every render hears again.
859-
_JOB_DECIDES = ("profile", "podcast", "vertical_live", "sport", "speaker_turns")
859+
_JOB_DECIDES = ("profile", "podcast", "vertical_live", "sport", "speaker_turns", "job_captions")
860860
# A webcam a person chose for the whole job (gaming/run.py TRUSTED): set up
861861
# for this video before processing, or remembered for the creator.
862862
_JOB_PERSON = ("user", "creator")
863863
_WEBCAMS = ("cam", "cam2")
864864

865865

866+
def captions_on(config: dict, opts: dict) -> bool:
867+
"""Whether a clip burns captions. The clip's own switch (the editor's
868+
Captions tab) wins; without one, the choice of the job that made it,
869+
saved with the clip as `job_captions` (#128: a clip made with captions
870+
off got them back at its first edit). Not `captions` itself, which only
871+
a person sets and which marks the clip as edited by hand."""
872+
return bool(config["clips"].get("captions", True)
873+
and opts.get("captions", opts.get("job_captions", True)))
874+
875+
866876
def _window(candidate) -> tuple[float, float]:
867877
"""A clip's window as its row is known by (core/state.py add_clip)."""
868878
return round(candidate.start, 2), round(candidate.end, 2)
@@ -1414,7 +1424,7 @@ def _render_files(
14141424
# Where someone other than the main speaker talks, when the caption style
14151425
# asks for them in a second colour (#126). Empty for every other clip.
14161426
turns: list = []
1417-
if config["clips"].get("captions", True) and opts.get("captions", True):
1427+
if captions_on(config, opts):
14181428
lines = opts.get("caption_lines") # user-corrected caption text, if any
14191429
if _wants_second_speaker(config, opts):
14201430
from video.captions import DEFAULT_STYLE, build_caption_lines, paint_turns, tag_lines, words_of
@@ -1634,6 +1644,11 @@ def _render_files(
16341644
# The other speaker's turns are the ones this file was burned with, or
16351645
# none: never ones left over from an earlier render.
16361646
opts = {k: v for k, v in opts.items() if k != "speaker_turns"}
1647+
job_captions = bool(config["clips"].get("captions", True)) and opts.get("job_captions", True)
1648+
# A run with captions off burns none, whatever a clip had switched on in
1649+
# an earlier one: saved, that switch would put them back at its next edit.
1650+
if not config["clips"].get("captions", True) and opts.get("captions"):
1651+
opts = {k: v for k, v in opts.items() if k != "captions"}
16371652
render_opts_json = json.dumps(
16381653
{
16391654
**opts,
@@ -1658,16 +1673,19 @@ def _render_files(
16581673
**({"watermark": wm_cfg} if wm_cfg else {}),
16591674
# The sport, so a re-render frames the ball again, not a face.
16601675
**({"sport": sport_name} if sport_name else {}),
1676+
# A job run with captions off, so an edit doesn't bring them back.
1677+
**({"job_captions": False} if not job_captions else {}),
16611678
}
1662-
) if (opts or caption_style or filter_name != "none" or wm_cfg or vertical_live or sport_name) else ""
1679+
) if (opts or caption_style or filter_name != "none" or wm_cfg or vertical_live or sport_name
1680+
or not job_captions) else ""
16631681
return final_path, render_opts_json
16641682

16651683

16661684
def _wants_second_speaker(config: dict, opts: dict) -> bool:
16671685
"""Whether a clip's captions burn the other speaker in a second colour:
16681686
captions on, the option ticked in its caption style, and not the
16691687
Highlights look, which has a colour of its own."""
1670-
if not (config["clips"].get("captions", True) and opts.get("captions", True)):
1688+
if not captions_on(config, opts):
16711689
return False
16721690
from video import post_style as _post_style
16731691

‎server/api.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ class PreviewIn(BaseModel):
193193
# pending hand fixes of who is talking (video/captions.py paint_turns);
194194
# None keeps the clip's saved ones, an empty list previews it with none
195195
speaker_edits: list | None = None
196+
# pending Captions on/off switch; None keeps the clip's saved choice
197+
captions: bool | None = None
196198

197199

198200
class CreatorGamingLayoutIn(BaseModel):
@@ -1736,7 +1738,7 @@ def ai_edit(clip_id: int, body: AiEditIn):
17361738
"end": row["end_s"],
17371739
"duration": round(row["end_s"] - row["start_s"], 1),
17381740
"crop": opts.get("crop", "track"),
1739-
"captions_enabled": opts.get("captions", True),
1741+
"captions_enabled": opts.get("captions", opts.get("job_captions", True)),
17401742
"caption_style": opts.get("caption_style", {}),
17411743
}
17421744

@@ -1851,6 +1853,8 @@ def preview_clip(clip_id: int, body: PreviewIn):
18511853
opts["subline"] = body.subline
18521854
if body.speaker_edits is not None:
18531855
opts["speaker_edits"] = body.speaker_edits
1856+
if body.captions is not None:
1857+
opts["captions"] = body.captions
18541858

18551859
candidate = ClipCandidate(
18561860
start=row["start_s"], end=row["end_s"],
@@ -1866,8 +1870,14 @@ def preview_clip(clip_id: int, body: PreviewIn):
18661870
rendered, _ = _render_files(
18671871
source, candidate, segments, prev_dir, config, opts, content_lang
18681872
)
1869-
out.unlink(missing_ok=True)
1870-
rendered.rename(out)
1873+
# The last preview may still be playing in the editor: Windows
1874+
# refuses deleting or renaming over it (#137), so put the new one
1875+
# in its place the way the end card does.
1876+
from video.outro import _replace_with_retry
1877+
1878+
if not _replace_with_retry(rendered, out, label="Preview"):
1879+
raise RuntimeError(f"{out.name} is open elsewhere and could not be written")
1880+
rendered.unlink(missing_ok=True)
18711881
except Exception as e:
18721882
raise HTTPException(500, f"preview render failed: {str(e)[:400]}")
18731883
import time as _time

‎tests/test_captions_off_kept.py‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""#128: a clip made with captions off got them back at its first edit. Off
2+
lived only in the job's settings, and an editor re-render uses the app's, so
3+
the clip now keeps the job's choice (`job_captions`), and the editor's
4+
Captions tab has a switch of its own (`captions`), which wins."""
5+
6+
import json
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
ROOT = Path(__file__).resolve().parent.parent
12+
EDITOR = ROOT / "ui" / "src" / "renderer" / "src" / "components" / "TimelineEditor.tsx"
13+
API_TS = ROOT / "ui" / "src" / "renderer" / "src" / "lib" / "api.ts"
14+
15+
16+
def _cfg(captions=True):
17+
return {"clips": {"captions": captions}}
18+
19+
20+
@pytest.fixture
21+
def pipeline():
22+
pytest.importorskip("numpy", reason="core.pipeline needs numpy, which CI does not install")
23+
from core import pipeline
24+
25+
return pipeline
26+
27+
28+
@pytest.mark.parametrize("captions, opts, expected", [
29+
(True, {}, True),
30+
(False, {}, False),
31+
(True, {"job_captions": False}, False), # an editor re-render of an off clip
32+
(True, {"job_captions": False, "captions": True}, True), # the switch turned back on
33+
(True, {"captions": False}, False), # the switch turned off
34+
])
35+
def test_whether_a_clip_burns_captions(pipeline, captions, opts, expected):
36+
assert pipeline.captions_on(_cfg(captions), opts) is expected
37+
38+
39+
def test_the_jobs_choice_is_not_an_edit_by_hand(pipeline):
40+
# "Make it again with my edits" must not take every clip of a captions-off
41+
# job for one a person edited, nor carry the old job's choice to a new run.
42+
assert not pipeline._edited({"job_captions": False})
43+
assert "job_captions" in pipeline._JOB_DECIDES
44+
45+
46+
@pytest.fixture
47+
def render(pipeline, monkeypatch, tmp_path):
48+
pytest.importorskip("cv2")
49+
from core import modes
50+
from core.models import ClipCandidate
51+
52+
burned = []
53+
54+
def fake_cut(source, candidate, output, ass_path=None, vf_extra="", normalize=True):
55+
burned.append(ass_path)
56+
Path(output).write_bytes(b"clip")
57+
58+
monkeypatch.setattr(pipeline, "cut_clip", fake_cut)
59+
monkeypatch.setattr(pipeline, "build_captions", lambda *a, **k: tmp_path / "clip.ass")
60+
monkeypatch.setattr(modes, "probe_size", lambda _path: (1080, 1920))
61+
62+
def run(captions, opts=None):
63+
config = {
64+
"clips": {"captions": captions, "outro": False, "vertical": True, "vertical_live": True},
65+
"paths": {"data_dir": str(tmp_path)},
66+
"tracking": {"detector": "yolov8n-pose.pt", "sample_fps": 8},
67+
}
68+
burned.clear()
69+
_final, opts_json = pipeline._render_files(
70+
tmp_path / "source.mp4", ClipCandidate(start=10.0, end=40.0, score=80), [],
71+
tmp_path / "clips", config, opts,
72+
)
73+
return json.loads(opts_json or "{}"), burned[0]
74+
75+
return run
76+
77+
78+
def test_a_captions_off_clip_stays_off_when_edited(render):
79+
saved, ass = render(captions=False)
80+
assert saved["job_captions"] is False and ass is None
81+
# The editor re-renders with the app's settings, where captions are on.
82+
resaved, ass = render(captions=True, opts=saved)
83+
assert ass is None and resaved["job_captions"] is False
84+
85+
86+
def test_the_editors_switch_turns_them_back_on(render):
87+
saved, _ = render(captions=False)
88+
_resaved, ass = render(captions=True, opts={**saved, "captions": True})
89+
assert ass is not None
90+
91+
92+
def test_a_captions_on_job_saves_nothing_new(render):
93+
saved, ass = render(captions=True)
94+
assert "job_captions" not in saved and ass is not None
95+
96+
97+
def test_the_editor_sends_its_switch_on_apply_and_preview():
98+
editor = EDITOR.read_text(encoding="utf-8")
99+
assert "if (captionsDirty) renderOpts.captions = captionsOn" in editor
100+
assert "captionsDirty ? captionsOn : undefined" in editor
101+
assert "clip.render_opts?.captions ?? clip.render_opts?.job_captions ?? true" in editor
102+
assert "captions: captions ?? null" in API_TS.read_text(encoding="utf-8")
103+
104+
105+
def test_a_clips_own_switch_from_an_earlier_run_is_not_saved_over_a_captions_off_run(render):
106+
# "Make it again with my edits" with captions off carries a clip's
107+
# captions:true; the file burns none, so neither may what is saved.
108+
saved, ass = render(captions=False, opts={"captions": True})
109+
assert ass is None and "captions" not in saved and saved["job_captions"] is False
110+
_resaved, ass = render(captions=True, opts=saved)
111+
assert ass is None
112+
113+
114+
def test_reset_and_the_translation_preview_read_the_same_switch():
115+
editor = EDITOR.read_text(encoding="utf-8")
116+
assert "setCaptionsOn(storedCaptionsOn)" in editor # Reset puts it back
117+
modal = (EDITOR.parent / "EditorModal.tsx").read_text(encoding="utf-8")
118+
assert "clip.render_opts?.captions ?? clip.render_opts?.job_captions ?? true" in modal
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""#138: the Generate row remembers its settings for the next video. Most
2+
switches already were; the caption style was read but never written, the
3+
Longform output picked from its list was never saved, and the Pipeline and
4+
Rate & understand plugins were forgotten. The row has no React test harness,
5+
so this is checked in its source, as tests/test_ui_steps.py does."""
6+
7+
import re
8+
from pathlib import Path
9+
10+
ROOT = Path(__file__).resolve().parent.parent
11+
ROW = ROOT / "ui" / "src" / "renderer" / "src" / "components" / "queue" / "AddVideos.tsx"
12+
13+
14+
def _source() -> str:
15+
return ROW.read_text(encoding="utf-8")
16+
17+
18+
def _pref(name: str) -> str:
19+
match = re.search(rf"\b{name}: '([a-z-]+)'", _source())
20+
assert match, f"PREF has no {name}"
21+
return match.group(1)
22+
23+
24+
def test_every_remembered_key_is_written_as_well_as_read():
25+
text = _source()
26+
for name in ("caption_style", "longform_mode", "pipeline", "rate", "understand", "edit"):
27+
key = _pref(name)
28+
assert key.startswith("generate-")
29+
assert text.count(f"PREF.{name}") + text.count("PREF[step]") >= 2, name
30+
# The style a new row starts from is the one last edited.
31+
assert "localStorage.setItem(PREF.caption_style, JSON.stringify(style))" in text
32+
assert "localStorage.getItem(PREF.caption_style)" in text
33+
34+
35+
def test_the_longform_output_picked_from_the_list_is_saved():
36+
text = _source()
37+
select = text[text.index("{t('Longform output')}"):]
38+
select = select[:select.index("</select>")]
39+
assert "localStorage.setItem(PREF.longform_mode, e.target.value)" in select
40+
41+
42+
def test_the_plugins_last_chosen_start_the_next_row():
43+
text = _source()
44+
start = text[text.index("function startingOptions"):]
45+
start = start[:start.index("\n}\n") if "\n}\n" in start else len(start)]
46+
assert "rememberedChoices(PREF.pipeline)" in start
47+
assert "rememberedChoices(PREF[step])" in start
48+
# Not with the modes that pick the moments themselves.
49+
assert "if (o.longform) return o" in start
50+
assert "!o.sport && !o.gaming_scoring" in start
51+
assert "return withoutPipelineInSteps(o)" in start
52+
# Written where they are chosen.
53+
assert "rememberChoices(PREF.pipeline, [pipeline])" in text
54+
assert "rememberChoices(PREF[step], list)" in text
55+
56+
57+
def test_a_remembered_plugin_that_has_gone_is_forgotten_too():
58+
# Dropped from the rows only, it came back on the row made after Generate,
59+
# and the server refused it.
60+
text = _source()
61+
assert "function forgetGone" in text
62+
for call in ("forgetGone(PREF.pipeline, pipelines)",
63+
"forgetGone(PREF[step], usableFor(stepPlugins, step))",
64+
"forgetGone(PREF.edit, editPlugins)"):
65+
assert call in text, call

‎tests/test_locked_clip_replace.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""#137: a watermark re-render failed with WinError 5 because the clip was open
2+
in the editor's player, and Windows refuses a rename over an open file. The
3+
watermark now puts its result in place the way the end card does: retry the
4+
rename, then write into the open file."""
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
pytest.importorskip("numpy", reason="video.outro draws the end card with numpy, which CI does not install")
10+
11+
from video import outro
12+
from video_editor import watermark
13+
14+
15+
def _locked(target: Path, monkeypatch):
16+
real = Path.replace
17+
18+
def replace(self, dst):
19+
if Path(dst) == target:
20+
raise PermissionError(5, "Access is denied", str(dst))
21+
return real(self, dst)
22+
23+
monkeypatch.setattr(Path, "replace", replace)
24+
monkeypatch.setattr(outro, "REPLACE_BUDGET", 0.2)
25+
monkeypatch.setattr(outro._replace_with_retry, "__defaults__", (0.2, "End card"))
26+
27+
28+
def test_a_watermark_reaches_a_clip_the_player_holds_open(tmp_path, monkeypatch):
29+
clip = tmp_path / "clip.mp4"
30+
clip.write_bytes(b"old clip bytes, longer than the new ones")
31+
assets = tmp_path / "assets"
32+
assets.mkdir()
33+
(assets / "logo.png").write_bytes(b"png")
34+
35+
def ffmpeg_run(cmd, **kwargs):
36+
Path(cmd[-1]).write_bytes(b"watermarked")
37+
38+
class Done:
39+
returncode, stderr = 0, ""
40+
return Done()
41+
42+
monkeypatch.setattr(watermark.subprocess, "run", ffmpeg_run)
43+
monkeypatch.setattr(watermark, "ffmpeg", lambda: "ffmpeg")
44+
monkeypatch.setattr(watermark, "video_encoder_args", lambda: [])
45+
_locked(clip, monkeypatch)
46+
47+
watermark.apply_image(clip, {"image_asset": "logo.png"}, (1080, 1920), assets)
48+
49+
assert clip.read_bytes() == b"watermarked"
50+
assert not clip.with_suffix(".wm.mp4").exists()

0 commit comments

Comments
 (0)