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
package/template/CLAUDE.md
CHANGED
|
@@ -33,6 +33,14 @@ out/ finished renders.
|
|
|
33
33
|
- `npm run ramp -- <clip> --ramp "3-5:1->0.4"` — eased speed ramps
|
|
34
34
|
- `npm run sync -- <a> <b>` — line up a second angle or a separate mic
|
|
35
35
|
- `npm run refstyle -- <reference clip>` — measure a reference edit's pacing and colour
|
|
36
|
+
- `npm run refmotion -- <reference clip>` — study HOW a reference moves and sounds: every move's speed-up and
|
|
37
|
+
settle, the holds between moves, every cut, every frame across each transition (strips to look at),
|
|
38
|
+
standout sounds and how far they lead the picture, plus a spectrogram of the sound. Run it on any clip
|
|
39
|
+
the person shares as a reference, then LOOK at the strips and spectrogram before planning — numbers
|
|
40
|
+
alone do not show how one scene turns into the next.
|
|
41
|
+
- `python scripts/track_head.py <clip> <first> <last> <out.json> --check <img>` — motion tracking: where the head
|
|
42
|
+
is, frame by frame (only when asked; see "Motion tracking" below)
|
|
43
|
+
- `python scripts/setup_models.py --language <code>` — (re)install the models for a video language
|
|
36
44
|
- `npm run stills -- <Comp> --every=45` — many stills at once, with contact sheets
|
|
37
45
|
- `npm run check` — typecheck plus motion lint. Must pass.
|
|
38
46
|
- `npm run motion-check -- <Comp> --scale=2` — frame-by-frame POP / CUT / STUTTER detection
|
|
@@ -83,6 +91,11 @@ that is when people can actually name what they like.
|
|
|
83
91
|
Then transcribe with word-level timings. **The transcript is the truth, not the script and not
|
|
84
92
|
what they remember saying.**
|
|
85
93
|
|
|
94
|
+
The language comes from setup (`studio.config.json`; the transcriber and aligner read it). For Indian
|
|
95
|
+
languages and Hinglish, **ask for the script or captions file first** and match it to the audio
|
|
96
|
+
(`python scripts/align-script.py <media> <script.srt> --whole`) — matching known words beats recognising
|
|
97
|
+
them, especially in code-mixed speech. Recognise only when there is no script.
|
|
98
|
+
|
|
86
99
|
### 2. The first cut, before the plan
|
|
87
100
|
|
|
88
101
|
Cut the silence, the filler and the retakes first. This is mechanical — no design decisions in
|
|
@@ -116,6 +129,35 @@ what stops motion graphics drifting into generic.
|
|
|
116
129
|
Then stop and ask: *here is the plan, change anything that is off, say go ahead and I will cut
|
|
117
130
|
it.* Wait for a yes. "Go ahead", "yes", "approved", "ship it" is a yes. Silence is not.
|
|
118
131
|
|
|
132
|
+
### 4. The live preview — after the yes, before any render
|
|
133
|
+
|
|
134
|
+
Once they approve, build the edit and **open the live preview yourself** — `preview_edit({video})`. They must
|
|
135
|
+
never have to ask for it, and never get a draft MP4 instead of it. It plays the edit in the chat with instant
|
|
136
|
+
controls (size, position, timing, sound, icon, follow head) that cost no messages; see `preview/README.md` for
|
|
137
|
+
what each video needs (`preview.tsx`, `TweaksProvider`).
|
|
138
|
+
|
|
139
|
+
- A change typed in the preview arrives as a chat message: change only that beat, then call `preview_edit`
|
|
140
|
+
again so the updated preview opens below. **Undo** = `preview_undo`, then `preview_edit`.
|
|
141
|
+
- **Save** and **Render** arrive as one message each, carrying their adjustments as JSON for the video's
|
|
142
|
+
`tweaks.json` — write it exactly.
|
|
143
|
+
- Render only when they press **Render final video**, and give the estimated render time only then.
|
|
144
|
+
- If the preview tool is missing (not switched on yet), say so in one line and fall back to rendering with
|
|
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.
|
|
150
|
+
|
|
151
|
+
### Motion tracking — only when asked
|
|
152
|
+
|
|
153
|
+
Text or graphics can ride with the speaker's head. **Never a default** — use it when they ask ("make the title
|
|
154
|
+
follow my head") or press **Follow head** in the preview. Track only the stretch that needs it:
|
|
155
|
+
`python scripts/track_head.py <clip> <first source frame> <last source frame> src/videos/<video>/head-track.json
|
|
156
|
+
--check work/<video>/track-check.jpg` (≈1 minute per 5 seconds), and **look at the check image** — the box must
|
|
157
|
+
sit on the face in every tile — before using it. Then either pin a graphic with `<HeadPin>`
|
|
158
|
+
(`src/components/HeadPin.tsx`) or turn on `follow` for the beat. A wide line will not fit above a head: split
|
|
159
|
+
it. It takes half the head's tilt and trails two frames — attached, not glued.
|
|
160
|
+
|
|
119
161
|
### Keep it proportionate
|
|
120
162
|
|
|
121
163
|
The plan is there to save them a rebuild, not to put a form in front of them.
|
package/template/package.json
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"stills": "node scripts/stills.mjs",
|
|
18
18
|
"refs": "node scripts/refs.mjs",
|
|
19
19
|
"refstyle": "node scripts/refstyle.mjs",
|
|
20
|
+
"refmotion": "node scripts/refmotion.mjs",
|
|
20
21
|
"beats": "node scripts/beats.mjs",
|
|
21
22
|
"sync": "node scripts/sync-audio.mjs",
|
|
22
23
|
"ramp": "node scripts/ramp.mjs",
|
|
@@ -25,16 +26,20 @@
|
|
|
25
26
|
"sound": "node scripts/sounddesign.mjs"
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|
|
28
|
-
"
|
|
29
|
-
"
|
|
29
|
+
"@modelcontextprotocol/ext-apps": "1.7.5",
|
|
30
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
30
31
|
"@remotion/cli": "4.0.525",
|
|
31
32
|
"@remotion/fonts": "4.0.525",
|
|
32
33
|
"@remotion/google-fonts": "4.0.525",
|
|
33
34
|
"@remotion/media-utils": "4.0.525",
|
|
34
35
|
"@remotion/motion-blur": "4.0.525",
|
|
36
|
+
"esbuild": "0.28.1",
|
|
37
|
+
"ffmpeg-static": "5.3.0",
|
|
38
|
+
"ffprobe-static": "3.1.0",
|
|
35
39
|
"react": "19.3.0",
|
|
36
40
|
"react-dom": "19.3.0",
|
|
37
|
-
"remotion": "4.0.525"
|
|
41
|
+
"remotion": "4.0.525",
|
|
42
|
+
"zod": "4.5.4"
|
|
38
43
|
},
|
|
39
44
|
"devDependencies": {
|
|
40
45
|
"@types/react": "19.3.0",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Live preview
|
|
2
|
+
|
|
3
|
+
The edit plays inside the chat, from its code. Nothing is rendered until you press **Render final video**.
|
|
4
|
+
|
|
5
|
+
- **The video** on the left; **every beat** on the right with its timestamp.
|
|
6
|
+
- **Instant controls under each beat** change the video on the spot — no chat, no waiting:
|
|
7
|
+
Size − / +, Move ↑ ↓, Timing Earlier / Later, Sound − / + / Mute, Icon, **Follow head** (the text rides
|
|
8
|
+
with the speaker's head — motion tracking), Reset.
|
|
9
|
+
- **Save** writes all your adjustments in one go; **Render final video** saves them and renders, and tells
|
|
10
|
+
you how long the render will take first.
|
|
11
|
+
- **"Something else? Describe it for Claude"** under a beat is for new ideas. It goes into the chat, Claude
|
|
12
|
+
makes the change, and the updated preview opens below. 📎 attaches a reference image (the app puts it in
|
|
13
|
+
your chat box — press Send).
|
|
14
|
+
- **Undo last change** puts the edit back exactly as it was before the last change.
|
|
15
|
+
|
|
16
|
+
Claude opens the preview by itself once you approve a plan. You never need to ask for it.
|
|
17
|
+
|
|
18
|
+
## How it is switched on
|
|
19
|
+
|
|
20
|
+
Setup registers it once (Claude Code: `claude mcp add live-preview --scope user -- node "<studio>/preview/server.mjs"`;
|
|
21
|
+
Codex: `codex mcp add live-preview -- node "<studio>/preview/server.mjs"`). Apps load these when a chat starts,
|
|
22
|
+
so it works from the next chat after setup.
|
|
23
|
+
|
|
24
|
+
## For Claude
|
|
25
|
+
|
|
26
|
+
- Open it with `preview_edit({video: "<folder in src/videos>"})` right after the plan is approved, and again
|
|
27
|
+
after every change you make. `preview_undo` restores the previous version.
|
|
28
|
+
- Each video needs `src/videos/<video>/preview.tsx` exporting `Comp` (the edit + its voice as
|
|
29
|
+
`<Audio src={staticFile('__stems/voice.mp3')} />`, taking `tweaks` and passing them to the edit), `META`,
|
|
30
|
+
`BEATS` (one per plan row: name, time, beat, says, from, to, icons?, follow?) and `TWEAKS` (saved tweaks,
|
|
31
|
+
the tweaks.json path, icons, limits, trackFile?). The edit applies tweaks with `TweaksProvider` / `Tweaked`
|
|
32
|
+
from `src/lib/tweaks.tsx` — full-screen cards that hide cut joins never go inside a moving `Tweaked`.
|
|
33
|
+
- The player cannot call this server or the computer (the app blocks it): everything it shows is packed into
|
|
34
|
+
the page when it opens, and it talks only by sending chat messages.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the live preview needs from a video, worked out once and used by both halves:
|
|
3
|
+
* server.mjs — the local preview server (our machine, reads files directly)
|
|
4
|
+
* publish.mjs — sends the same things to the online preview, so it works in any chat with no restart
|
|
5
|
+
*
|
|
6
|
+
* For a video it finds: the preview module (Comp, META, BEATS), every file the edit reads with
|
|
7
|
+
* staticFile() as a small preview copy, and the edit compiled to one script.
|
|
8
|
+
*/
|
|
9
|
+
import {spawnSync} from 'node:child_process';
|
|
10
|
+
import {createHash} from 'node:crypto';
|
|
11
|
+
import {createRequire} from 'node:module';
|
|
12
|
+
import {existsSync, mkdirSync, readdirSync, readFileSync, statSync} from 'node:fs';
|
|
13
|
+
import {dirname, extname, join, resolve} from 'node:path';
|
|
14
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
15
|
+
|
|
16
|
+
export const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
export const STUDIO = resolve(HERE, '..');
|
|
18
|
+
export const PUBLIC = join(STUDIO, 'assets');
|
|
19
|
+
export const SRC = join(STUDIO, 'src');
|
|
20
|
+
const CACHE = join(HERE, '.cache');
|
|
21
|
+
mkdirSync(CACHE, {recursive: true});
|
|
22
|
+
|
|
23
|
+
const require = createRequire(join(STUDIO, 'package.json'));
|
|
24
|
+
const esbuild = require('esbuild');
|
|
25
|
+
const {FFMPEG} = await import(pathToFileURL(join(STUDIO, 'scripts', 'lib', 'media.mjs')).href);
|
|
26
|
+
|
|
27
|
+
/* ----------------------------------------------------------------------------- which video */
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Everything the preview needs to know about one video, read from its own folder:
|
|
31
|
+
* src/videos/<name>/preview.tsx, the *Edit.tsx beside it, the asset folders its code mentions, its stems.
|
|
32
|
+
*/
|
|
33
|
+
export function configFor(name) {
|
|
34
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name ?? '')) throw new Error(`"${name}" is not a video name (lowercase letters, digits, dashes)`);
|
|
35
|
+
const code = join(SRC, 'videos', name);
|
|
36
|
+
if (!existsSync(join(code, 'preview.tsx'))) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`No src/videos/${name}/preview.tsx. Write it first: export Comp (the edit + its voice as <Audio src={staticFile('__stems/voice.mp3')} />), META and BEATS — copy src/videos/noufal/preview.tsx.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const source = readdirSync(code)
|
|
42
|
+
.filter((f) => /\.(tsx?|json)$/.test(f))
|
|
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')] : [])
|
|
47
|
+
.join('\n');
|
|
48
|
+
const folders = new Set(['fonts']);
|
|
49
|
+
// Only the fonts this edit names — the studio has many, and each one is ~1 MB in the page.
|
|
50
|
+
const fonts = [...new Set([...source.matchAll(/fonts\/([\w.-]+\.(?:ttf|otf|woff2?))/g)].map((m) => m[1]))];
|
|
51
|
+
// The whole folder path up to the file name — cutouts live a level deeper (cutouts/<shot>/f123.png).
|
|
52
|
+
for (const m of source.matchAll(new RegExp(`projects/${name}/((?:[\\w-]+/)+)`, 'g'))) folders.add(`projects/${name}/${m[1].replace(/\/$/, '')}`);
|
|
53
|
+
for (const f of folders) if (!existsSync(join(PUBLIC, f))) folders.delete(f);
|
|
54
|
+
|
|
55
|
+
const stems = {};
|
|
56
|
+
if (existsSync(join(STUDIO, 'work', name, 'voice.wav'))) stems.voice = `work/${name}/voice.wav`;
|
|
57
|
+
const out = join(STUDIO, 'out', name);
|
|
58
|
+
if (existsSync(out))
|
|
59
|
+
for (const f of readdirSync(out)) {
|
|
60
|
+
const m = f.match(/^stem-(\w+)\.wav$/);
|
|
61
|
+
if (m) stems[m[1]] = `out/${name}/${f}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The edit is the file named *Edit.tsx; its composition id is that name (as registered in Root.tsx).
|
|
65
|
+
const editFile = readdirSync(code).find((f) => /Edit\.tsx$/.test(f));
|
|
66
|
+
return {
|
|
67
|
+
project: name,
|
|
68
|
+
preview: `src/videos/${name}/preview.tsx`,
|
|
69
|
+
edit: `src/videos/${name}/${editFile ?? 'preview.tsx'}`,
|
|
70
|
+
composition: editFile?.replace(/\.tsx$/, '') ?? null,
|
|
71
|
+
assets: [...folders],
|
|
72
|
+
fonts,
|
|
73
|
+
stems,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ----------------------------------------------------------------------------- the footage */
|
|
78
|
+
|
|
79
|
+
/** Every file the edit asks for by name (staticFile), plus the stems. Keys are the names the edit uses. */
|
|
80
|
+
function assetList(c) {
|
|
81
|
+
const files = {};
|
|
82
|
+
const addDir = (rel) => {
|
|
83
|
+
const dir = join(PUBLIC, rel);
|
|
84
|
+
if (!existsSync(dir)) return;
|
|
85
|
+
for (const f of readdirSync(dir)) {
|
|
86
|
+
if (rel === 'fonts' && c.fonts && !c.fonts.includes(f)) continue;
|
|
87
|
+
if (statSync(join(dir, f)).isFile()) files[`${rel}/${f}`] = join(dir, f);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
for (const d of c.assets) addDir(d);
|
|
91
|
+
for (const [k, f] of Object.entries(c.stems ?? {})) if (existsSync(join(STUDIO, f))) files[`__stems/${k}.mp3`] = join(STUDIO, f);
|
|
92
|
+
return Object.fromEntries(Object.entries(files).filter(([k]) => /\.(mp4|mov|png|jpe?g|webp|svg|ttf|woff2?|mp3)$/i.test(k)));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Preview copies: small enough to travel inside the player page, good enough to judge the edit in a chat-
|
|
97
|
+
* sized player. Anything fits a 540px box on its longest side. The final render always uses the originals.
|
|
98
|
+
*/
|
|
99
|
+
const FIT = "scale='if(gt(iw,ih),min(540,iw),-2)':'if(gt(iw,ih),-2,min(540,ih))'";
|
|
100
|
+
const PROXY = {
|
|
101
|
+
video: ['-vf', FIT, '-an', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '31', '-g', '15', '-pix_fmt', 'yuv420p', '-movflags', '+faststart'],
|
|
102
|
+
image: ['-vf', FIT, '-c:v', 'libwebp', '-quality', '72'],
|
|
103
|
+
audio: ['-vn', '-ac', '2', '-ar', '44100', '-c:a', 'libmp3lame', '-b:a', '128k'],
|
|
104
|
+
};
|
|
105
|
+
const MIME = {'.mp4': 'video/mp4', '.webp': 'image/webp', '.mp3': 'audio/mpeg', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.woff': 'font/woff', '.woff2': 'font/woff2', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png'};
|
|
106
|
+
|
|
107
|
+
function proxyOf(name, original) {
|
|
108
|
+
const st = statSync(original);
|
|
109
|
+
// The settings are part of the key, so changing them makes new copies instead of reusing old ones.
|
|
110
|
+
const key = createHash('sha1').update(`${original}:${st.size}:${st.mtimeMs}:${JSON.stringify(PROXY)}`).digest('hex').slice(0, 16);
|
|
111
|
+
const ext = extname(name).toLowerCase();
|
|
112
|
+
const kind = /\.(mp4|mov)$/.test(ext) ? 'video' : /\.(png|jpe?g)$/.test(ext) ? 'image' : ext === '.mp3' ? 'audio' : null;
|
|
113
|
+
if (!kind) return {file: original, mime: MIME[ext] ?? 'application/octet-stream'};
|
|
114
|
+
const outExt = kind === 'video' ? '.mp4' : kind === 'image' ? '.webp' : '.mp3';
|
|
115
|
+
const out = join(CACHE, `${key}${outExt}`);
|
|
116
|
+
if (!existsSync(out)) {
|
|
117
|
+
const r = spawnSync(FFMPEG, ['-y', '-v', 'error', '-i', original, ...PROXY[kind], out], {encoding: 'utf8'});
|
|
118
|
+
if (r.status !== 0) throw new Error(`could not make a preview copy of ${name}: ${r.stderr}`);
|
|
119
|
+
}
|
|
120
|
+
return {file: out, mime: MIME[outExt]};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** [{name, file, mime, size, hash}] — hash is of the preview copy, so unchanged files are never re-sent. */
|
|
124
|
+
export function buildManifest(c) {
|
|
125
|
+
return Object.entries(assetList(c)).map(([name, original]) => {
|
|
126
|
+
const p = proxyOf(name, original);
|
|
127
|
+
const hash = createHash('sha256').update(readFileSync(p.file)).digest('hex').slice(0, 32);
|
|
128
|
+
return {name, file: p.file, mime: p.mime, size: statSync(p.file).size, hash};
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ----------------------------------------------------------------------------- the edit, compiled */
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The edit runs inside the player, so it must use the player's own React and Remotion — two copies
|
|
136
|
+
* would break every hook. Those imports become reads of window.__shared.
|
|
137
|
+
*/
|
|
138
|
+
const SHARED = ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'remotion'];
|
|
139
|
+
const sharedPlugin = {
|
|
140
|
+
name: 'shared',
|
|
141
|
+
setup(b) {
|
|
142
|
+
b.onResolve({filter: new RegExp(`^(${SHARED.map((s) => s.replace('/', '\\/')).join('|')})$`)}, (a) => ({path: a.path, namespace: 'shared'}));
|
|
143
|
+
b.onLoad({filter: /.*/, namespace: 'shared'}, (a) => {
|
|
144
|
+
const keys = Object.keys(require(a.path)).filter((k) => /^[A-Za-z_$][\w$]*$/.test(k) && k !== 'default');
|
|
145
|
+
return {
|
|
146
|
+
contents: `const m = window.__shared[${JSON.stringify(a.path)}];\nexport default m;\n${keys.map((k) => `export const ${k} = m.${k};`).join('\n')}`,
|
|
147
|
+
loader: 'js',
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const common = {bundle: true, jsx: 'automatic', logLevel: 'silent', loader: {'.png': 'dataurl', '.svg': 'dataurl'}};
|
|
154
|
+
|
|
155
|
+
/** The edit as one script that sets window.__lpComp. Throws with a readable message if it doesn't compile. */
|
|
156
|
+
export async function compileEdit(c) {
|
|
157
|
+
try {
|
|
158
|
+
const r = await esbuild.build({...common, entryPoints: [join(STUDIO, c.preview)], format: 'iife', globalName: '__lpComp', write: false, plugins: [sharedPlugin], define: {'process.env.NODE_ENV': '"development"'}});
|
|
159
|
+
return r.outputFiles[0].text;
|
|
160
|
+
} catch (e) {
|
|
161
|
+
throw new Error((e.errors ?? []).map((x) => `${x.location?.file ?? ''}:${x.location?.line ?? ''} ${x.text}`).join('\n') || String(e));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The player page. Customers get it pre-built (player.html, made when the studio ships), so they need none of
|
|
167
|
+
* the player's build tools; in the source studio it is built from ui/app.tsx.
|
|
168
|
+
*/
|
|
169
|
+
export async function playerHtml() {
|
|
170
|
+
const built = join(HERE, 'player.html');
|
|
171
|
+
if (existsSync(built) && !existsSync(join(HERE, 'ui', 'app.tsx'))) return readFileSync(built, 'utf8');
|
|
172
|
+
return buildUi();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function buildUi() {
|
|
176
|
+
const r = await esbuild.build({...common, entryPoints: [join(HERE, 'ui', 'app.tsx')], format: 'iife', write: false, minify: true, define: {'process.env.NODE_ENV': '"production"'}});
|
|
177
|
+
const js = r.outputFiles[0].text;
|
|
178
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
179
|
+
<body><div id="root"></div><script>${js.replace(/<\/script>/g, '<\\/script>')}</script></body></html>`;
|
|
180
|
+
}
|