creator-editing-studio 1.3.7 → 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 +4 -0
- package/template/preview/lib.mjs +3 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "creator-editing-studio",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.8",
|
|
4
4
|
"description": "Scaffolds a Creator Editing OS studio — components, scripts and rules for editing video from a plain-English brief.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
12
|
"template",
|
|
13
|
+
"media",
|
|
13
14
|
"history.json",
|
|
14
15
|
"README.md"
|
|
15
16
|
],
|
package/template/CLAUDE.md
CHANGED
|
@@ -143,6 +143,10 @@ what each video needs (`preview.tsx`, `TweaksProvider`).
|
|
|
143
143
|
- Render only when they press **Render final video**, and give the estimated render time only then.
|
|
144
144
|
- If the preview tool is missing (not switched on yet), say so in one line and fall back to rendering with
|
|
145
145
|
the time estimate. Never send them off to fix it mid-edit.
|
|
146
|
+
- **"What's new" / "show me what's new"** → `preview_edit({video: "whats-new"})`: a short demo with one new
|
|
147
|
+
effect per beat, named on screen. Open it after every update too. It is a sandbox: let them press every
|
|
148
|
+
button. Save works as usual; if they press Render, say in one line that it is the demo, and that the same
|
|
149
|
+
buttons appear on every video of their own.
|
|
146
150
|
|
|
147
151
|
### Motion tracking — only when asked
|
|
148
152
|
|
package/template/preview/lib.mjs
CHANGED
|
@@ -41,6 +41,9 @@ export function configFor(name) {
|
|
|
41
41
|
const source = readdirSync(code)
|
|
42
42
|
.filter((f) => /\.(tsx?|json)$/.test(f))
|
|
43
43
|
.map((f) => readFileSync(join(code, f), 'utf8'))
|
|
44
|
+
// The brand's own font files are named in tokens.ts, not in the video — without this the preview of
|
|
45
|
+
// every video fell back to a system font.
|
|
46
|
+
.concat(existsSync(join(SRC, 'design', 'tokens.ts')) ? [readFileSync(join(SRC, 'design', 'tokens.ts'), 'utf8')] : [])
|
|
44
47
|
.join('\n');
|
|
45
48
|
const folders = new Set(['fonts']);
|
|
46
49
|
// Only the fonts this edit names — the studio has many, and each one is ~1 MB in the page.
|
|
@@ -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';
|