creator-editing-studio 1.3.6 → 1.3.8
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.
- package/bin/finish.mjs +132 -0
- package/bin/init.mjs +5 -1
- package/bin/update.mjs +18 -0
- package/history.json +1 -1
- package/package.json +2 -1
- package/template/CLAUDE.md +42 -0
- package/template/package.json +8 -3
- package/template/preview/README.md +34 -0
- package/template/preview/lib.mjs +180 -0
- package/template/preview/player.html +255 -0
- package/template/preview/server.mjs +161 -0
- package/template/preview/use.mjs +22 -0
- package/template/requirements.txt +4 -0
- package/template/scripts/align-script.py +138 -0
- package/template/scripts/doctor.mjs +27 -1
- package/template/scripts/lib/languages.py +68 -0
- package/template/scripts/refmotion.mjs +349 -0
- package/template/scripts/setup_models.py +89 -0
- package/template/scripts/stills.mjs +1 -1
- package/template/scripts/track_head.py +157 -0
- package/template/scripts/transcribe.py +74 -5
- package/template/src/components/HeadPin.tsx +42 -0
- package/template/src/lib/track.ts +67 -0
- package/template/src/lib/tweaks.tsx +98 -0
- package/template/src/videos/whats-new/WhatsNewEdit.tsx +175 -0
- package/template/src/videos/whats-new/beats.ts +44 -0
- package/template/src/videos/whats-new/head-track.json +5195 -0
- package/template/src/videos/whats-new/preview.tsx +36 -0
- package/template/src/videos/whats-new/tweaks.json +1 -0
- package/template/src/videos/whats-new/words.json +1 -0
|
@@ -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.
|
|
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=
|
|
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
|
-
|
|
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
|
|
71
|
-
|
|
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
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {AbsoluteFill, useCurrentFrame} from 'remotion';
|
|
3
|
+
import {Captions} from '../../components/Captions';
|
|
4
|
+
import {HeadPin} from '../../components/HeadPin';
|
|
5
|
+
import {DataLine} from '../../components/DataLine';
|
|
6
|
+
import {MorphState} from '../../components/MorphState';
|
|
7
|
+
import {TypeAhead} from '../../components/TypeAhead';
|
|
8
|
+
import {EASE} from '../../design/motion';
|
|
9
|
+
import {COLOR, FONT, WEIGHT} from '../../design/tokens';
|
|
10
|
+
import {progress} from '../../lib/animate';
|
|
11
|
+
import {sampleTrack, type TrackFile} from '../../lib/track';
|
|
12
|
+
import {TweaksProvider, Tweaked, type Tweaks} from '../../lib/tweaks';
|
|
13
|
+
import {BEATS, FRAMES, WORDS} from './beats';
|
|
14
|
+
import headTrack from './head-track.json';
|
|
15
|
+
import SAVED_TWEAKS from './tweaks.json';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* whats-new — the demo every studio carries: 25 seconds, one new effect per beat, each named by a small
|
|
19
|
+
* label. It opens in the live preview after an update so the person SEES what arrived and can try the
|
|
20
|
+
* buttons on something that is not their own work.
|
|
21
|
+
*
|
|
22
|
+
* NO REAL PERSON. No footage and no voice ship — only the words (as captions) and the motion of a head
|
|
23
|
+
* recorded once (head-track.json: positions, nothing else). A plain stand-in silhouette rides that path,
|
|
24
|
+
* so motion tracking and "Follow head" have something to follow. Colours and type are the studio's own
|
|
25
|
+
* tokens, so it appears in the customer's brand.
|
|
26
|
+
*
|
|
27
|
+
* 1280×720, the size the track was recorded at, so its pixels are the composition's pixels.
|
|
28
|
+
*/
|
|
29
|
+
export {FRAMES};
|
|
30
|
+
export const WIDTH = 1280;
|
|
31
|
+
export const HEIGHT = 720;
|
|
32
|
+
const TRACK = headTrack as TrackFile;
|
|
33
|
+
/** One unbroken clip: composition frame = source frame. */
|
|
34
|
+
export const toSource = (f: number) => (f >= 0 && f < FRAMES ? f : null);
|
|
35
|
+
|
|
36
|
+
const W = (i: number) => WORDS[i].f;
|
|
37
|
+
const SHADOW = '0 2px 18px rgba(0,0,0,0.45)';
|
|
38
|
+
|
|
39
|
+
/** The stand-in: a soft head-and-shoulders shape on the recorded head path (the track sits at the hairline). */
|
|
40
|
+
const StandIn: React.FC = () => {
|
|
41
|
+
const f = useCurrentFrame();
|
|
42
|
+
const p = sampleTrack(TRACK, f);
|
|
43
|
+
if (!p) return null;
|
|
44
|
+
const fill = 'rgba(255,255,255,0.10)';
|
|
45
|
+
return (
|
|
46
|
+
<div style={{position: 'absolute', left: p.x, top: p.y, transform: `translate(-50%, 0) scale(${p.s}) rotate(${p.r}deg)`, transformOrigin: '50% 140px'}}>
|
|
47
|
+
<div style={{position: 'absolute', left: -125, top: -10, width: 250, height: 310, borderRadius: '50% 50% 46% 46%', background: fill}} />
|
|
48
|
+
<div style={{position: 'absolute', left: -330, top: 330, width: 660, height: 420, borderRadius: '300px 300px 0 0', background: fill}} />
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** In over 8 frames at `from`, out over 6 before `to`. */
|
|
54
|
+
const useInOut = (from: number, to: number) => {
|
|
55
|
+
const f = useCurrentFrame();
|
|
56
|
+
if (f < from || f >= to) return 0;
|
|
57
|
+
return Math.min(progress(f, from, 8, EASE.out), 1 - progress(f, to - 6, 6, EASE.in));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The effect's name, top left, for the length of its beat. */
|
|
61
|
+
const Label: React.FC<{text: string; from: number; to: number}> = ({text, from, to}) => {
|
|
62
|
+
const o = useInOut(from, to);
|
|
63
|
+
if (!o) return null;
|
|
64
|
+
return (
|
|
65
|
+
<div style={{position: 'absolute', left: 48, top: 40, opacity: o, transform: `translateY(${(1 - o) * -10}px)`, display: 'flex', alignItems: 'center', gap: 10, fontFamily: FONT.sans}}>
|
|
66
|
+
<span style={{background: COLOR.accent, color: COLOR.onAccent, fontSize: 15, fontWeight: WEIGHT.bold, letterSpacing: '0.08em', padding: '5px 10px', borderRadius: 999}}>NEW</span>
|
|
67
|
+
<span style={{color: COLOR.fgOnDark, fontSize: 24, fontWeight: WEIGHT.semibold, textShadow: SHADOW}}>{text}</span>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Beat 4: the jobs, landing one by one as they are named. */
|
|
73
|
+
const JOBS: [string, number][] = [
|
|
74
|
+
['Cut the gaps', W(41)],
|
|
75
|
+
['Add captions', W(44)],
|
|
76
|
+
['Bring in b-roll', W(46)],
|
|
77
|
+
['Zooms', W(49)],
|
|
78
|
+
['Motion graphics', W(53)],
|
|
79
|
+
];
|
|
80
|
+
const Jobs: React.FC<{to: number}> = ({to}) => {
|
|
81
|
+
const f = useCurrentFrame();
|
|
82
|
+
const out = 1 - progress(f, to - 6, 6, EASE.in);
|
|
83
|
+
if (f >= to || f < JOBS[0][1] - 2) return null;
|
|
84
|
+
return (
|
|
85
|
+
<div style={{position: 'absolute', left: 930, top: 170, display: 'flex', flexDirection: 'column', gap: 18, fontFamily: FONT.sans, opacity: out}}>
|
|
86
|
+
{JOBS.map(([text, at]) => {
|
|
87
|
+
const p = progress(f, at - 2, 9, EASE.overshoot);
|
|
88
|
+
if (p <= 0) return null;
|
|
89
|
+
return (
|
|
90
|
+
<div key={text} style={{display: 'flex', alignItems: 'center', gap: 14, opacity: Math.min(1, p * 1.6), transform: `translateX(${(1 - Math.min(1, p)) * 40}px)`}}>
|
|
91
|
+
<span style={{width: 14, height: 14, borderRadius: 999, background: COLOR.accent, boxShadow: SHADOW}} />
|
|
92
|
+
<span style={{color: COLOR.fgOnDark, fontSize: 34, fontWeight: WEIGHT.bold, textShadow: SHADOW}}>{text}</span>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
})}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** A slow push-in on "zooms", easing back out before the next beat. */
|
|
101
|
+
const zoomAt = (f: number) => {
|
|
102
|
+
const inP = progress(f, W(52) - 4, 18, EASE.inOut);
|
|
103
|
+
const outP = progress(f, W(56) - 16, 14, EASE.inOut);
|
|
104
|
+
return 1 + 0.07 * inP * (1 - outP);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const WhatsNewEdit: React.FC<{tweaks?: Tweaks}> = ({tweaks = SAVED_TWEAKS as Tweaks}) => {
|
|
108
|
+
const f = useCurrentFrame();
|
|
109
|
+
const [hook, simple, , list, morph, data] = BEATS;
|
|
110
|
+
const words = React.useMemo(() => WORDS.map((w) => ({text: w.text, start: w.f / 30, end: w.t / 30, paragraph: 0, matched: true})), []);
|
|
111
|
+
return (
|
|
112
|
+
<TweaksProvider tweaks={tweaks} beatStarts={BEATS.map((b) => b.fromF)} track={TRACK} toSource={toSource}>
|
|
113
|
+
<AbsoluteFill style={{background: COLOR.surfaceDark}}>
|
|
114
|
+
<AbsoluteFill style={{transform: `scale(${zoomAt(f)})`, transformOrigin: '50% 40%', background: 'radial-gradient(ellipse at 50% 30%, rgba(255,255,255,0.07), transparent 65%)'}}>
|
|
115
|
+
<StandIn />
|
|
116
|
+
</AbsoluteFill>
|
|
117
|
+
|
|
118
|
+
<Tweaked>
|
|
119
|
+
{BEATS.map((b) => (
|
|
120
|
+
<Label key={b.name} text={b.name} from={b.fromF} to={b.toF} />
|
|
121
|
+
))}
|
|
122
|
+
|
|
123
|
+
{/* 1 · Motion tracking: the tag is pinned to the head. */}
|
|
124
|
+
<HeadPin track={TRACK} toSource={toSource} from={W(2)} to={hook.toF} dx={350} dy={90}>
|
|
125
|
+
<div style={{fontFamily: FONT.sans, color: COLOR.onAccent, background: COLOR.accent, fontSize: 28, fontWeight: WEIGHT.bold, padding: '8px 18px', borderRadius: 999, boxShadow: SHADOW, whiteSpace: 'nowrap'}}>
|
|
126
|
+
← this follows your head
|
|
127
|
+
</div>
|
|
128
|
+
</HeadPin>
|
|
129
|
+
|
|
130
|
+
{/* 2 · Type-ahead */}
|
|
131
|
+
{f >= simple.fromF && f < simple.toF ? (
|
|
132
|
+
<div style={{position: 'absolute', left: 60, top: 300, opacity: 1 - progress(f, simple.toF - 6, 6, EASE.in)}}>
|
|
133
|
+
<TypeAhead text="The idea is pretty simple." at={W(16)} speed={1.1} colour={COLOR.onAccent} ghost="rgba(0,0,0,0.28)" background={COLOR.accent} fontSize={34} fontFamily={FONT.sans} fontWeight={WEIGHT.bold} />
|
|
134
|
+
</div>
|
|
135
|
+
) : null}
|
|
136
|
+
|
|
137
|
+
{/* 4 · The list that builds */}
|
|
138
|
+
<Jobs to={list.toF} />
|
|
139
|
+
|
|
140
|
+
{/* 5 · Morph: the same pill relabels itself */}
|
|
141
|
+
{f >= W(59) - 4 && f < morph.toF ? (
|
|
142
|
+
<div style={{position: 'absolute', left: 70, top: 300, opacity: Math.min(progress(f, W(59) - 4, 8, EASE.out), 1 - progress(f, morph.toF - 6, 6, EASE.in))}}>
|
|
143
|
+
<MorphState
|
|
144
|
+
at={W(66)}
|
|
145
|
+
from={{width: 210, height: 64, background: 'rgba(255,255,255,0.92)', colour: '#111', children: <span style={{fontFamily: FONT.sans, fontSize: 28, fontWeight: WEIGHT.bold}}>Raw clip</span>}}
|
|
146
|
+
to={{width: 300, height: 64, background: COLOR.accent, colour: COLOR.onAccent, children: <span style={{fontFamily: FONT.sans, fontSize: 28, fontWeight: WEIGHT.bold}}>Ready to post ✓</span>}}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
) : null}
|
|
150
|
+
|
|
151
|
+
{/* 6 · Live data line: time spent editing, dropping on "saves" */}
|
|
152
|
+
{f >= data.fromF ? (
|
|
153
|
+
<div style={{position: 'absolute', left: 900, top: 190, fontFamily: FONT.sans, opacity: progress(f, data.fromF, 8, EASE.out)}}>
|
|
154
|
+
<div style={{color: COLOR.fgOnDark, fontSize: 24, fontWeight: WEIGHT.semibold, textShadow: SHADOW, marginBottom: 10}}>Hours spent editing</div>
|
|
155
|
+
<DataLine
|
|
156
|
+
from={[0.85, 0.8, 0.88, 0.78, 0.84, 0.8]}
|
|
157
|
+
to={[0.85, 0.62, 0.44, 0.3, 0.18, 0.1]}
|
|
158
|
+
drawAt={data.fromF + 2}
|
|
159
|
+
drawFrames={14}
|
|
160
|
+
at={W(70)}
|
|
161
|
+
width={320}
|
|
162
|
+
height={150}
|
|
163
|
+
colour={COLOR.fgOnDark}
|
|
164
|
+
toColour={COLOR.accent}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
168
|
+
</Tweaked>
|
|
169
|
+
|
|
170
|
+
{/* 3 · Captions, word by word, all the way through */}
|
|
171
|
+
<Captions words={words} placement={() => ({x: 240, y: 610, width: 800, align: 'center', size: 34})} />
|
|
172
|
+
</AbsoluteFill>
|
|
173
|
+
</TweaksProvider>
|
|
174
|
+
);
|
|
175
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* whats-new — the short demo every studio carries, so the person can see the newest effects working on a
|
|
3
|
+
* short sample and try the live preview's buttons on it before they edit anything of their own.
|
|
4
|
+
*
|
|
5
|
+
* Each beat shows one effect and names it in a small label, so nobody needs the vocabulary to follow.
|
|
6
|
+
* A beat runs from its first word to the next beat's first word.
|
|
7
|
+
*/
|
|
8
|
+
import data from './words.json';
|
|
9
|
+
|
|
10
|
+
export const WORDS = data.words as {text: string; f: number; t: number}[];
|
|
11
|
+
export const FRAMES = data.frames;
|
|
12
|
+
|
|
13
|
+
/** [first word, name, what is on screen (and the effect it shows)] */
|
|
14
|
+
const PLAN: [number, string, string][] = [
|
|
15
|
+
[0, 'Motion tracking', 'A label rides with the head — moves, scales and tilts with it'],
|
|
16
|
+
[15, 'Type-ahead', 'The sentence types into a pill that grows with it, the rest already faint'],
|
|
17
|
+
[21, 'Word-by-word captions', 'Captions sharpen in word by word, on the spoken timing'],
|
|
18
|
+
[32, 'A list that builds', 'Each job lands as it is named; a slow zoom on "zooms"'],
|
|
19
|
+
[56, 'Morph', 'One pill turns from "Raw clip" into "Ready to post" — the same object, relabelled'],
|
|
20
|
+
[68, 'Live data line', 'A line draws on, then drops on "saves"'],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
|
|
24
|
+
|
|
25
|
+
export const BEATS = PLAN.map(([a, name, beat], i) => {
|
|
26
|
+
const fromF = i === 0 ? 0 : WORDS[a].f;
|
|
27
|
+
const next = PLAN[i + 1];
|
|
28
|
+
const toF = next ? WORDS[next[0]].f : FRAMES;
|
|
29
|
+
const last = next ? next[0] - 1 : WORDS.length - 1;
|
|
30
|
+
return {
|
|
31
|
+
name,
|
|
32
|
+
beat,
|
|
33
|
+
icons: [] as string[],
|
|
34
|
+
fromF,
|
|
35
|
+
toF,
|
|
36
|
+
from: fromF / 30,
|
|
37
|
+
to: toF / 30,
|
|
38
|
+
time: `${mmss(fromF / 30)}–${mmss(toF / 30)}`,
|
|
39
|
+
says: WORDS.slice(a, last + 1).map((w) => w.text).join(' '),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export type {Tweak, Tweaks} from '../../lib/tweaks';
|
|
44
|
+
export {TWEAK_LIMITS as LIMITS} from '../../lib/tweaks';
|