creator-editing-studio 1.3.6 → 1.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@ from pathlib import Path
17
17
  import sys as _sys # noqa: E402
18
18
  _sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
19
19
  from media import FFMPEG, use_certifi # noqa: E402
20
+ from languages import lang, load_aligner, studio_language # noqa: E402
20
21
 
21
22
  # Models are large; cache them inside the studio so they survive and are easy to find.
22
23
  _MODELS = Path(__file__).resolve().parent.parent / ".models"
@@ -49,12 +50,46 @@ except ModuleNotFoundError as exc: # pragma: no cover
49
50
  raise SystemExit(2)
50
51
 
51
52
 
53
+ # Indian languages: the small model cannot do them. On Malayalam it ignored the speech, invented one
54
+ # English sentence and repeated it, then drifted into Devanagari (2026-10-17). large-v3 is the
55
+ # smallest model that transcribes these reliably, so it is the default for them.
56
+ INDIC = {"ml", "ta", "te", "kn", "hi", "bn", "mr", "gu", "pa", "or", "as", "ur", "ne", "si"}
57
+
58
+ # The script each language must come out in. A transcript mostly in another script is a model
59
+ # failure (translated, or a neighbouring language), never a usable result, so it stops here.
60
+ SCRIPTS = {
61
+ "ml": (0x0D00, 0x0D7F), "ta": (0x0B80, 0x0BFF), "te": (0x0C00, 0x0C7F), "kn": (0x0C80, 0x0CFF),
62
+ "hi": (0x0900, 0x097F), "mr": (0x0900, 0x097F), "ne": (0x0900, 0x097F), "bn": (0x0980, 0x09FF),
63
+ "as": (0x0980, 0x09FF), "gu": (0x0A80, 0x0AFF), "pa": (0x0A00, 0x0A7F), "or": (0x0B00, 0x0B7F),
64
+ "si": (0x0D80, 0x0DFF), "ur": (0x0600, 0x06FF),
65
+ }
66
+
67
+
68
+ def script_share(text: str, language: str) -> float | None:
69
+ """Share of letters in the language's own script (Latin words like brand names are allowed)."""
70
+ span = SCRIPTS.get(language)
71
+ if not span:
72
+ return None
73
+ letters = [c for c in text if c.isalpha()]
74
+ if not letters:
75
+ return 0.0
76
+ return sum(span[0] <= ord(c) <= span[1] for c in letters) / len(letters)
77
+
78
+
52
79
  def main() -> None:
80
+ # Windows consoles default to cp1252, which cannot print Malayalam: the script used to finish
81
+ # the transcription and then crash on its own summary line.
82
+ for stream in (_sys.stdout, _sys.stderr):
83
+ try:
84
+ stream.reconfigure(encoding="utf-8", errors="replace")
85
+ except (AttributeError, ValueError):
86
+ pass
87
+
53
88
  parser = argparse.ArgumentParser()
54
89
  parser.add_argument("media")
55
- parser.add_argument("--language", default=None, help="e.g. en, hi. Detected when omitted.")
90
+ parser.add_argument("--language", default=None, help="e.g. en, hi, ml. Default: the language chosen at setup (studio.config.json), else detected.")
56
91
  parser.add_argument("--project", default=None, help="Video project name: writes to work/<project>/<clip>/")
57
- parser.add_argument("--model", default="small", help="Whisper size: small (fast on CPU) or medium (more accurate)")
92
+ parser.add_argument("--model", default=None, help="Whisper size. Default: what the language needs (scripts/lib/languages.py)")
58
93
  args = parser.parse_args()
59
94
 
60
95
  media = Path(args.media)
@@ -62,13 +97,35 @@ def main() -> None:
62
97
  out_dir.mkdir(parents=True, exist_ok=True)
63
98
  started = time.time()
64
99
 
100
+ if args.language is None:
101
+ args.language = studio_language()
102
+ info = lang(args.language) if args.language else None
103
+ if info:
104
+ args.language = info["code"] # Hinglish is transcribed as Hindi
105
+ if args.model is None:
106
+ args.model = info["whisper"] if info else "small"
65
107
  audio = whisperx.load_audio(str(media))
66
108
  model = whisperx.load_model(args.model, device="cpu", compute_type="int8", language=args.language)
67
- result = model.transcribe(audio, batch_size=4, language=args.language)
109
+ # task="transcribe" always: without it a weak model may answer in English instead.
110
+ result = model.transcribe(audio, batch_size=4, language=args.language, task="transcribe")
68
111
  language = result["language"]
69
112
 
70
- align_model, metadata = whisperx.load_align_model(language_code=language, device="cpu")
71
- aligned = whisperx.align(result["segments"], align_model, metadata, audio, device="cpu", return_char_alignments=False)
113
+ align_model, metadata = load_aligner(whisperx, language)
114
+ if align_model is not None:
115
+ aligned = whisperx.align(result["segments"], align_model, metadata, audio, device="cpu", return_char_alignments=False)
116
+ else:
117
+ # No word-timing model for this language: spread each segment's words across its time, by length.
118
+ # Looser than real alignment — good enough to cut on, worth checking where captions must land exactly.
119
+ aligned = {"segments": []}
120
+ for seg in result["segments"]:
121
+ ws = seg["text"].split()
122
+ total = sum(len(w) for w in ws) or 1
123
+ t, words_ = seg["start"], []
124
+ for w in ws:
125
+ d = (seg["end"] - seg["start"]) * len(w) / total
126
+ words_.append({"word": w, "start": t, "end": t + d})
127
+ t += d
128
+ aligned["segments"].append({**seg, "words": words_})
72
129
 
73
130
  words = []
74
131
  for segment in aligned["segments"]:
@@ -93,6 +150,18 @@ def main() -> None:
93
150
  out_file = out_dir / "words.json"
94
151
  out_file.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
95
152
 
153
+ share = script_share(output["text"], language)
154
+ # Low bar on purpose: everyday Malayalam mixes in English words ("Instagram", "business"), but a
155
+ # failed run scores near zero (the small model's Malayalam came out at 0%).
156
+ if share is not None and share < 0.3:
157
+ print(
158
+ f"WARNING: only {share:.0%} of this transcript is in the {language} script, so the model did not "
159
+ f"really transcribe it (it translated, or heard another language). Do not cut from it. "
160
+ f"Re-run with a larger --model, and check --language is right.",
161
+ file=_sys.stderr,
162
+ )
163
+ raise SystemExit(3)
164
+
96
165
  unplaced = [w["word"] for w in words if w["start"] is None]
97
166
  print(f"language: {language} | words: {len(words)} | unplaced: {len(unplaced)} {unplaced if unplaced else ''}")
98
167
  print(f"text: {output['text']}")
@@ -0,0 +1,42 @@
1
+ import React from 'react';
2
+ import {useCurrentFrame} from 'remotion';
3
+ import {EASE} from '../design/motion';
4
+ import {progress} from '../lib/animate';
5
+ import {pinStyle, sampleTrack, type TrackFile} from '../lib/track';
6
+
7
+ /**
8
+ * Text or a graphic that rides with the speaker's head (motion tracking). Never a default — use it when
9
+ * someone asks for it, on a stretch that has been tracked with scripts/track_head.py.
10
+ *
11
+ * <HeadPin track={headTrack} toSource={(f) => f + 72} from={5} to={53} dy={-330}>
12
+ * <div style={…}>Big title</div>
13
+ * </HeadPin>
14
+ *
15
+ * dx/dy place it relative to the head (at the head's starting size; they grow as the head comes closer).
16
+ * It follows two frames behind and takes half the head's tilt — attached, not glued. Outside the tracked
17
+ * frames it simply does not draw, so it can never float in the wrong place.
18
+ */
19
+ export const HeadPin: React.FC<{
20
+ track: TrackFile;
21
+ /** The source frame of the tracked clip shown at a composition frame (null when the clip is not on screen). */
22
+ toSource: (frame: number) => number | null;
23
+ from: number;
24
+ to: number;
25
+ dx?: number;
26
+ dy?: number;
27
+ tilt?: number;
28
+ children: React.ReactNode;
29
+ }> = ({track, toSource, from, to, dx = 0, dy = 0, tilt = 0.5, children}) => {
30
+ const f = useCurrentFrame();
31
+ if (f < from - 2 || f > to) return null;
32
+ const src = toSource(f);
33
+ const head = src === null ? null : sampleTrack(track, src, 2);
34
+ if (!head) return null;
35
+ const p = progress(f, from, 8, EASE.overshoot);
36
+ const out = 1 - progress(f, to - 5, 5, EASE.in);
37
+ return (
38
+ <div style={{...pinStyle(head, {dx, dy, tilt}), opacity: Math.min(1, p * 1.6) * out}}>
39
+ <div style={{transform: `scale(${0.85 + Math.min(1.05, p) * 0.15}) translateY(${(1 - Math.min(1, p)) * 20}px)`, display: 'flex', flexDirection: 'column', alignItems: 'center'}}>{children}</div>
40
+ </div>
41
+ );
42
+ };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Motion tracking: pin graphics to something moving in the footage — a head, from scripts/track_head.py.
3
+ *
4
+ * const head = sampleTrack(track, sourceFrame, 2); // null outside the tracked stretches
5
+ * if (head) <div style={pinStyle(head, {dy: -330})}>…</div>
6
+ *
7
+ * A track holds SOURCE frames of one clip, in that clip's pixels; a composition showing the clip
8
+ * full-frame at the same size can use it directly. One file per video can hold several tracked stretches.
9
+ * `lag` follows a couple of frames behind, so a graphic feels attached by a soft spring, not glued on.
10
+ */
11
+ import type {CSSProperties} from 'react';
12
+
13
+ export type TrackPoint = {f: number; x: number; y: number; s: number; r: number};
14
+ export type TrackFile = {fps: number; size: number[]; first: number; box: {w: number; h: number}; frames: TrackPoint[]};
15
+ export type Pin = {x: number; y: number; s: number; r: number};
16
+
17
+ /** The tracked point at a source frame, or null if that frame was not tracked. */
18
+ function pointAt(t: TrackFile, f: number): TrackPoint | null {
19
+ const fr = Math.round(f);
20
+ let lo = 0;
21
+ let hi = t.frames.length - 1;
22
+ while (lo <= hi) {
23
+ const mid = (lo + hi) >> 1;
24
+ const m = t.frames[mid];
25
+ if (m.f === fr) return m;
26
+ if (m.f < fr) lo = mid + 1;
27
+ else hi = mid - 1;
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /** Where the tracked thing is at a source frame, averaged over `lag` frames behind it; null if untracked. */
33
+ export function sampleTrack(t: TrackFile, sourceFrame: number, lag = 0): Pin | null {
34
+ const pts = Array.from({length: lag + 1}, (_, k) => pointAt(t, sourceFrame - k)).filter((p): p is TrackPoint => !!p);
35
+ if (!pointAt(t, sourceFrame) || !pts.length) return null;
36
+ const avg = (k: 'x' | 'y' | 's' | 'r') => pts.reduce((sum, p) => sum + p[k], 0) / pts.length;
37
+ return {x: avg('x'), y: avg('y'), s: avg('s'), r: avg('r')};
38
+ }
39
+
40
+ /**
41
+ * Absolute position for a graphic pinned to the track. dx/dy are offsets at the starting size and grow with
42
+ * the head; `tilt` is how much of the head's tilt the graphic takes (a little reads as attached; all of it
43
+ * reads as stuck on).
44
+ */
45
+ export function pinStyle(p: Pin, {dx = 0, dy = 0, tilt = 0.5}: {dx?: number; dy?: number; tilt?: number} = {}): CSSProperties {
46
+ return {
47
+ position: 'absolute',
48
+ left: p.x + dx * p.s,
49
+ top: p.y + dy * p.s,
50
+ transform: `translate(-50%, -50%) scale(${p.s}) rotate(${p.r * tilt}deg)`,
51
+ transformOrigin: '50% 50%',
52
+ };
53
+ }
54
+
55
+ /**
56
+ * How far the head has moved since a reference frame, as a CSS transform about the head — for "follow":
57
+ * a layout keeps its design but moves, scales and (partly) tilts with the head. Null if either is untracked.
58
+ */
59
+ export function followTransform(t: TrackFile, sourceFrame: number, fromSourceFrame: number, tilt = 0.5): {transform: string; transformOrigin: string} | null {
60
+ const now = sampleTrack(t, sourceFrame, 2);
61
+ const at = sampleTrack(t, fromSourceFrame);
62
+ if (!now || !at) return null;
63
+ return {
64
+ transform: `translate(${now.x - at.x}px, ${now.y - at.y}px) scale(${now.s / at.s}) rotate(${(now.r - at.r) * tilt}deg)`,
65
+ transformOrigin: `${at.x}px ${at.y}px`,
66
+ };
67
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Live-preview adjustments ("tweaks"): what the preview's instant controls change, applied to an edit
3
+ * without touching its code.
4
+ *
5
+ * An edit wraps itself in <TweaksProvider beatStarts={…} tweaks={…}> and wraps each layer that should
6
+ * respond in <Tweaked>. In the preview the tweaks arrive as props and change the video instantly; saved,
7
+ * they live in the video's tweaks.json and the final render reads them from there. No tweaks = the edit
8
+ * exactly as built.
9
+ *
10
+ * Rules that keep an edit correct whatever someone clicks:
11
+ * - Anything that hides a join in the cut (a full-screen card) must NOT be inside a moving <Tweaked> — the
12
+ * card's exact frames are what hide the join. Wrap only what sits ON it.
13
+ * - Layers locked to the footage (a subject cutout) use <Tweaked move={false}> and read the tweak with
14
+ * useHereTweak() to move only their graphics.
15
+ */
16
+ import React, {createContext, useContext} from 'react';
17
+ import {AbsoluteFill, Sequence, useCurrentFrame} from 'remotion';
18
+ import {followTransform, type TrackFile} from './track';
19
+
20
+ /**
21
+ * One beat's adjustments. All optional.
22
+ * scale size of text and graphics, 0.7–1.4 dy up (−) / down (+), px, ±300
23
+ * shift frames later (+) / earlier (−), ±12 gain that beat's sound effects, dB, −12…+6
24
+ * mute that beat's sound effects off icon swap a 3D icon: {cart: 'shop'}
25
+ * follow the beat's graphics move, scale and partly tilt with the speaker's head (needs a head track)
26
+ */
27
+ export type Tweak = {scale?: number; dy?: number; shift?: number; gain?: number; mute?: boolean; icon?: Record<string, string>; follow?: boolean};
28
+ export type Tweaks = Record<string, Tweak>; // keyed by beat index
29
+
30
+ export const TWEAK_LIMITS = {scale: [0.7, 1.4], dy: [-300, 300], shift: [-12, 12], gain: [-12, 6]} as const;
31
+
32
+ /** track/toSource: optional head track for the "follow" tweak — the clip's source frame at a composition frame. */
33
+ type Ctx = {tweaks: Tweaks; beatAt: (frame: number) => number; beatStarts: number[]; track?: TrackFile; toSource?: (frame: number) => number | null};
34
+ const All = createContext<Ctx>({tweaks: {}, beatAt: () => 0, beatStarts: [0]});
35
+ const Here = createContext<Tweak>({});
36
+
37
+ /** beatStarts: the first frame of each beat, in order. */
38
+ export const TweaksProvider: React.FC<{tweaks?: Tweaks; beatStarts: number[]; track?: TrackFile; toSource?: (frame: number) => number | null; children: React.ReactNode}> = ({
39
+ tweaks = {},
40
+ beatStarts,
41
+ track,
42
+ toSource,
43
+ children,
44
+ }) => {
45
+ const beatAt = (frame: number) => {
46
+ for (let i = beatStarts.length - 1; i >= 0; i--) if (frame >= beatStarts[i]) return i;
47
+ return 0;
48
+ };
49
+ return <All.Provider value={{tweaks, beatAt, beatStarts, track, toSource}}>{children}</All.Provider>;
50
+ };
51
+
52
+ /** The tweak of the beat at any frame — for things placed by frame number, like sound cues. */
53
+ export const useTweakAt = () => {
54
+ const {tweaks, beatAt} = useContext(All);
55
+ return (frame: number): Tweak => tweaks[String(beatAt(frame))] ?? {};
56
+ };
57
+
58
+ /** The tweak of the beat being drawn, inside a <Tweaked>. */
59
+ export const useHereTweak = () => useContext(Here);
60
+
61
+ /**
62
+ * Applies the current beat's size, position and timing to what is inside it. `move={false}` only hands the
63
+ * tweak down (see the rules above).
64
+ */
65
+ export const Tweaked: React.FC<{move?: boolean; children: React.ReactNode}> = ({move = true, children}) => {
66
+ const f = useCurrentFrame();
67
+ const ctx = useContext(All);
68
+ const t = useTweakAt()(f);
69
+ // Follow: the beat keeps its layout but moves, scales and partly tilts with the head, measured from the
70
+ // first frame of the beat where the head is visible and tracked (a beat may open on a full-screen card).
71
+ // Untracked frames leave it exactly where it was designed.
72
+ let follow: React.CSSProperties | null = null;
73
+ if (move && t.follow && ctx.track && ctx.toSource) {
74
+ const track = ctx.track;
75
+ const src = ctx.toSource(f);
76
+ const beat = ctx.beatAt(f);
77
+ let src0: number | null = null;
78
+ for (let g = ctx.beatStarts[beat] ?? 0; g <= f && src0 === null; g++) {
79
+ const s = ctx.toSource(g);
80
+ if (s !== null && followTransform(track, s, s)) src0 = s;
81
+ }
82
+ follow = src !== null && src0 !== null ? followTransform(track, src, src0) : null;
83
+ }
84
+ const placed = (
85
+ <Here.Provider value={t}>
86
+ {move && (t.scale || t.dy) ? <AbsoluteFill style={{transform: `translateY(${t.dy ?? 0}px) scale(${t.scale ?? 1})`, transformOrigin: '50% 55%'}}>{children}</AbsoluteFill> : children}
87
+ </Here.Provider>
88
+ );
89
+ const inner = follow ? <AbsoluteFill style={follow}>{placed}</AbsoluteFill> : placed;
90
+ // Later (+) or earlier (−): everything inside runs on a clock offset by that many frames.
91
+ return move && t.shift ? (
92
+ <Sequence from={t.shift} layout="none">
93
+ {inner}
94
+ </Sequence>
95
+ ) : (
96
+ inner
97
+ );
98
+ };