@tongue-core/project 0.0.0-stage → 0.1.0-rc.10
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/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/build/bundle.d.ts +21 -0
- package/dist/build/bundle.js +74 -0
- package/dist/build/compile-project.d.ts +16 -0
- package/dist/build/compile-project.js +63 -0
- package/dist/build/compile.d.ts +27 -0
- package/dist/build/compile.js +108 -0
- package/dist/build/extract.d.ts +242 -0
- package/dist/build/extract.js +67 -0
- package/dist/build/index.d.ts +7 -0
- package/dist/build/index.js +10 -0
- package/dist/build/layout.d.ts +36 -0
- package/dist/build/layout.js +79 -0
- package/dist/build/load-project.d.ts +19 -0
- package/dist/build/load-project.js +27 -0
- package/dist/build/write.d.ts +39 -0
- package/dist/build/write.js +41 -0
- package/dist/doc/assemble.d.ts +697 -0
- package/dist/doc/assemble.js +161 -0
- package/dist/doc/build-doc.d.ts +74 -0
- package/dist/doc/build-doc.js +58 -0
- package/dist/doc/check.d.ts +463 -0
- package/dist/doc/check.js +131 -0
- package/dist/doc/clip-assets.d.ts +8 -0
- package/dist/doc/clip-assets.js +57 -0
- package/dist/doc/film-doc.d.ts +897 -0
- package/dist/doc/film-doc.js +204 -0
- package/dist/doc/from-plan.d.ts +239 -0
- package/dist/doc/from-plan.js +118 -0
- package/dist/doc/index.d.ts +8 -0
- package/dist/doc/index.js +11 -0
- package/dist/doc/issues.d.ts +12 -0
- package/dist/doc/issues.js +22 -0
- package/dist/doc/plan-doc.d.ts +233 -0
- package/dist/doc/plan-doc.js +13 -0
- package/dist/errors.d.ts +63 -0
- package/dist/errors.js +47 -0
- package/dist/hash.d.ts +2 -0
- package/dist/hash.js +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +10 -0
- package/dist/load/host.d.ts +34 -0
- package/dist/load/host.js +35 -0
- package/dist/load/index.d.ts +4 -0
- package/dist/load/index.js +7 -0
- package/dist/load/modules.d.ts +5 -0
- package/dist/load/modules.js +29 -0
- package/dist/load/open-build.d.ts +16 -0
- package/dist/load/open-build.js +31 -0
- package/dist/load/scene.d.ts +17 -0
- package/dist/load/scene.js +17 -0
- package/dist/media/media.d.ts +67 -0
- package/dist/media/media.js +76 -0
- package/dist/order.d.ts +2 -0
- package/dist/order.js +3 -0
- package/dist/template/index.d.ts +1 -0
- package/dist/template/index.js +3 -0
- package/dist/template/template.d.ts +54 -0
- package/dist/template/template.js +71 -0
- package/package.json +54 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Backpine
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tongue-core/project
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Film projects. A film project is a folder in a fixed shape: `tongue.json`, `film.tsx` (the
|
|
4
|
+
timeline) and one scene per file in `scenes/`. Building one gives **film.json**, the timeline as
|
|
5
|
+
type-safe data that timeline UIs read, and one **bundle** per scene. Renderers, the browser player
|
|
6
|
+
and Workers turn film.json plus the bundles back into the film, and the frames are byte-identical
|
|
7
|
+
to the ones the project's own `film.tsx` gives. [docs/film-json.md](../../docs/film-json.md) is the
|
|
8
|
+
specification.
|
|
9
|
+
|
|
10
|
+
No Bun, Node or ffmpeg here: the package runs in Bun, browsers and Workers alike (its tsconfig
|
|
11
|
+
leaves out Bun's and Node's types, so a platform API is a type error). Hosts supply what is
|
|
12
|
+
platform-specific: the project's files, and a `ModuleHost` that turns a bundle's code into a module.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun add @tongue-core/project@next effect # effect (^4.0.6) is a peer: the schemas are yours to run
|
|
16
|
+
```
|
|
17
|
+
The SDK is that host on a machine with files and processes.
|
|
18
|
+
|
|
19
|
+
## Entry points
|
|
20
|
+
|
|
21
|
+
| Import | Holds | For |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `@tongue-core/project/doc` | the `FilmDoc` schema and types, `decodeFilmDoc` and `filmDocProblems` (does it hold together), `assembleFilm` and `planFilmDoc` (back to a film), `sceneFilmDoc` (one scene alone), `filmDocOf` (a plan as film.json), build.json | timeline UIs, anything that reads or edits film.json |
|
|
24
|
+
| `@tongue-core/project/build` | the project's shape (`roleOf`), the compiler (`compileModule`), bundles, `compileProject` → `loadProject` → `extractFilm` → `writeBuild`, or `buildProject` for all four | whatever builds projects (the SDK, a backend) |
|
|
25
|
+
| `@tongue-core/project/load` | `ModuleHost`, linking bundles, `openBuild` (build.json, film.json and every bundle, hashes checked), `filmFromModules` (bundles a bundler already imported) | renderers, players, Workers |
|
|
26
|
+
| `@tongue-core/project/template` | the template format (`template.json`), `TemplateSource`, `projectFromTemplate` | `tongue init`, apps with template catalogs |
|
|
27
|
+
| `@tongue-core/project/media` | media identities, prepared-media schema, shared HTTP transport and typed failures | SDK acquisition, browser playback, host storage adapters |
|
|
28
|
+
|
|
29
|
+
`@tongue-core/project` exports all of it.
|
|
30
|
+
|
|
31
|
+
## Building
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { buildProject, type ModuleHost } from "@tongue-core/project/build";
|
|
35
|
+
import { Effect } from "effect";
|
|
36
|
+
|
|
37
|
+
const host: ModuleHost = { load: (bundle) => import(urlOf(bundle)) }; // however this platform loads a module
|
|
38
|
+
const build = await Effect.runPromise(buildProject(files, host, { tongue: "0.1.0", media }));
|
|
39
|
+
build.doc; // FilmDoc: the timeline as data
|
|
40
|
+
build.files; // build.json, film.json, scenes/<id>.<hash>.js
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
1. **Compile.** Every `.ts`, `.tsx` and `.json` file is checked against the project's shape and
|
|
44
|
+
compiled with [sucrase](https://github.com/alangpierce/sucrase): TypeScript out, JSX to
|
|
45
|
+
`@tongue-core/film`'s development runtime (so each element knows its file and line), imports to
|
|
46
|
+
`require` calls resolved against the project's files.
|
|
47
|
+
2. **Bundle.** Each scene and everything it imports becomes one ES module that imports nothing and
|
|
48
|
+
exports `link(imports)`. The timeline gets a bundle too; it links the scenes as `scene:<id>`.
|
|
49
|
+
Bundles are named by their SHA-256, and the same files give the same bytes.
|
|
50
|
+
3. **Load.** The host loads each bundle; `link` runs it against this package's own `@tongue-core/film`
|
|
51
|
+
(the film package recognises its assets, scenes and nodes by identity, so there must be one
|
|
52
|
+
copy). The assets each bundle declares are collected as it runs.
|
|
53
|
+
4. **Extract.** The timeline is planned with stable ids and written as film.json: props and the kit
|
|
54
|
+
by their scenes' `t` schemas, assets by name, every clip with its origin. Then film.json is
|
|
55
|
+
assembled with the scenes and planned again, and must give film.json back exactly; anything
|
|
56
|
+
else fails the build (it would be a bug in Tongue).
|
|
57
|
+
|
|
58
|
+
Hosts that learn media lengths by importing assets (the SDK does, with ffprobe) call
|
|
59
|
+
`compileProject`, `loadProject` and `extractFilm` themselves, importing in between.
|
|
60
|
+
|
|
61
|
+
## Errors
|
|
62
|
+
|
|
63
|
+
Every failure is a `Schema.TaggedError` naming its file, line or path, with what to change:
|
|
64
|
+
`LayoutError` (every problem with the project's shape at once), `CompileError`, `LoadError`,
|
|
65
|
+
`TimelineError` (it doesn't plan, or uses what film.json can't hold yet), `DocError` (film.json
|
|
66
|
+
doesn't decode or hold together, with each problem's path) and `TemplateError`.
|
|
67
|
+
|
|
68
|
+
## Layout
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
src/
|
|
72
|
+
doc/ film-doc.ts (the schema), check.ts, assemble.ts, from-plan.ts, plan-doc.ts, build-doc.ts, issues.ts
|
|
73
|
+
build/ layout.ts (the shape), compile.ts, bundle.ts, compile-project.ts, load-project.ts, extract.ts, write.ts
|
|
74
|
+
load/ host.ts (ModuleHost, linking), scene.ts, open-build.ts, modules.ts
|
|
75
|
+
template/ template.ts
|
|
76
|
+
errors.ts, hash.ts, order.ts
|
|
77
|
+
test/ builds a fixture project (test/fixtures/basic) end to end, document checks, templates
|
|
78
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CompiledModule, Link } from "./compile.js";
|
|
2
|
+
export declare const BUNDLE_FORMAT = 1;
|
|
3
|
+
export interface Bundle {
|
|
4
|
+
/** Where it sits in a build: scenes/<id>.<hash>.js, or film.<hash>.js. */
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly code: string;
|
|
7
|
+
/** SHA-256 of `code`, hex. */
|
|
8
|
+
readonly hash: string;
|
|
9
|
+
/** The module whose exports `link` returns. */
|
|
10
|
+
readonly entry: string;
|
|
11
|
+
/** The project modules in it, sorted. */
|
|
12
|
+
readonly modules: ReadonlyArray<string>;
|
|
13
|
+
}
|
|
14
|
+
/** The name a link target has inside a bundle (and in `imports`, when it is outside). */
|
|
15
|
+
export declare const linkName: (link: Link) => string;
|
|
16
|
+
/** The bundle of `entry`. `describe` says what it is, for its first line ("scene \"hook\""). */
|
|
17
|
+
export declare function bundleOf(entry: string, modules: ReadonlyMap<string, CompiledModule>, place: {
|
|
18
|
+
readonly dir: string;
|
|
19
|
+
readonly stem: string;
|
|
20
|
+
readonly describe: string;
|
|
21
|
+
}): Promise<Bundle>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { sha256 } from "../hash.js";
|
|
2
|
+
import { byText } from "../order.js";
|
|
3
|
+
// A bundle: one module and everything it imports from the project, in one ES module that imports
|
|
4
|
+
// nothing. Its `link(imports)` runs the modules once, CommonJS style, and returns the entry's
|
|
5
|
+
// exports; `imports` supplies what lives outside the bundle (the film runtime, and for a film's
|
|
6
|
+
// timeline its scenes). So a host loads a bundle however it loads modules (a file in Bun, a URL in
|
|
7
|
+
// a browser, a module of a Dynamic Worker) and always links its own one copy of @tongue-core/film.
|
|
8
|
+
export const BUNDLE_FORMAT = 1;
|
|
9
|
+
/** The name a link target has inside a bundle (and in `imports`, when it is outside). */
|
|
10
|
+
export const linkName = (link) => (link.kind === "module" ? link.path : link.kind === "runtime" ? link.name : `scene:${link.id}`);
|
|
11
|
+
/** Every project module `entry` reaches, sorted. */
|
|
12
|
+
function reachable(entry, modules) {
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
const visit = (path) => {
|
|
15
|
+
if (seen.has(path))
|
|
16
|
+
return;
|
|
17
|
+
const m = modules.get(path);
|
|
18
|
+
if (!m)
|
|
19
|
+
return;
|
|
20
|
+
seen.add(path);
|
|
21
|
+
for (const link of m.links.values())
|
|
22
|
+
if (link.kind === "module")
|
|
23
|
+
visit(link.path);
|
|
24
|
+
};
|
|
25
|
+
visit(entry);
|
|
26
|
+
return [...seen].sort();
|
|
27
|
+
}
|
|
28
|
+
const LOADER = ` const cache = new Map();
|
|
29
|
+
const load = (id) => {
|
|
30
|
+
const done = cache.get(id);
|
|
31
|
+
if (done) return done.exports;
|
|
32
|
+
const definition = modules.get(id);
|
|
33
|
+
if (!definition) {
|
|
34
|
+
if (Object.prototype.hasOwnProperty.call(imports, id)) return imports[id];
|
|
35
|
+
throw new Error(\`This bundle needs "\${id}": pass it to link().\`);
|
|
36
|
+
}
|
|
37
|
+
const module = { exports: {} };
|
|
38
|
+
cache.set(id, module);
|
|
39
|
+
const [links, run] = definition;
|
|
40
|
+
run.call(undefined, module, module.exports, (specifier) => {
|
|
41
|
+
const target = links[specifier];
|
|
42
|
+
if (target === undefined) throw new Error(\`\${id} requires "\${specifier}", which this bundle doesn't link.\`);
|
|
43
|
+
return load(target);
|
|
44
|
+
});
|
|
45
|
+
return module.exports;
|
|
46
|
+
};`;
|
|
47
|
+
/** The bundle of `entry`. `describe` says what it is, for its first line ("scene \"hook\""). */
|
|
48
|
+
export async function bundleOf(entry, modules, place) {
|
|
49
|
+
const included = reachable(entry, modules);
|
|
50
|
+
const outside = [...new Set(included.flatMap((p) => [...(modules.get(p)?.links.values() ?? [])].filter((l) => l.kind !== "module").map(linkName)))].sort();
|
|
51
|
+
const definitions = included.map((path) => {
|
|
52
|
+
const m = modules.get(path);
|
|
53
|
+
const links = Object.fromEntries([...(m?.links ?? new Map())].sort(([a], [b]) => byText(a, b)).map(([specifier, link]) => [specifier, linkName(link)]));
|
|
54
|
+
return ` [${JSON.stringify(path)}, [${JSON.stringify(links)}, function (module, exports, require) {\n${m?.code ?? ""}\n}]],`;
|
|
55
|
+
});
|
|
56
|
+
const others = included.length - 1;
|
|
57
|
+
const code = [
|
|
58
|
+
`// Tongue bundle (format ${BUNDLE_FORMAT}): ${place.describe}, from ${entry}${others > 0 ? ` and the ${others} module${others === 1 ? "" : "s"} it imports` : ""}.`,
|
|
59
|
+
`// It imports nothing. link(imports) runs its modules once and returns ${entry}'s exports;`,
|
|
60
|
+
`// imports: ${outside.map((o) => JSON.stringify(o)).join(", ")}.`,
|
|
61
|
+
`export const format = ${BUNDLE_FORMAT};`,
|
|
62
|
+
`export const entry = ${JSON.stringify(entry)};`,
|
|
63
|
+
`export function link(imports) {`,
|
|
64
|
+
` const modules = new Map([`,
|
|
65
|
+
...definitions,
|
|
66
|
+
` ]);`,
|
|
67
|
+
LOADER,
|
|
68
|
+
` return load(entry);`,
|
|
69
|
+
`}`,
|
|
70
|
+
``,
|
|
71
|
+
].join("\n");
|
|
72
|
+
const hash = await sha256(code);
|
|
73
|
+
return { path: `${place.dir}${place.stem}.${hash.slice(0, 12)}.js`, code, hash, entry, modules: included };
|
|
74
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { CompileError, LayoutError } from "../errors.js";
|
|
3
|
+
import { type Bundle } from "./bundle.js";
|
|
4
|
+
import { type CompiledModule } from "./compile.js";
|
|
5
|
+
import { ProjectManifest, type ProjectFiles } from "./layout.js";
|
|
6
|
+
export interface CompiledProject {
|
|
7
|
+
readonly manifest: ProjectManifest;
|
|
8
|
+
readonly modules: ReadonlyMap<string, CompiledModule>;
|
|
9
|
+
/** Each scene's bundle, by scene id, in id order. */
|
|
10
|
+
readonly scenes: ReadonlyMap<string, Bundle>;
|
|
11
|
+
/** film.tsx's bundle: it links the scene bundles. */
|
|
12
|
+
readonly film: Bundle;
|
|
13
|
+
/** SHA-256 of every code file, path and text: what the build was made from. */
|
|
14
|
+
readonly source: string;
|
|
15
|
+
}
|
|
16
|
+
export declare const compileProject: (files: ProjectFiles) => Effect.Effect<CompiledProject, LayoutError | CompileError, never>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { CompileError, LayoutError } from "../errors.js";
|
|
3
|
+
import { bundleOf } from "./bundle.js";
|
|
4
|
+
import { compileModule } from "./compile.js";
|
|
5
|
+
import { sha256 } from "../hash.js";
|
|
6
|
+
import { byText } from "../order.js";
|
|
7
|
+
import { FILM_FILE, isProblem, normalizePath, PROJECT_FILE, ProjectManifest, roleOf } from "./layout.js";
|
|
8
|
+
const decodeManifest = Schema.decodeUnknownEffect(ProjectManifest);
|
|
9
|
+
const layoutError = (problems) => new LayoutError({ problems, message: `The project isn't in the shape of a film project:\n${problems.map((p) => ` ${p.file ? `${p.file}: ` : ""}${p.message}`).join("\n")}` });
|
|
10
|
+
export const compileProject = Effect.fn("compileProject")(function* (files) {
|
|
11
|
+
const problems = [];
|
|
12
|
+
const roles = new Map();
|
|
13
|
+
const texts = new Map();
|
|
14
|
+
for (const [raw, text] of files) {
|
|
15
|
+
const path = normalizePath(raw);
|
|
16
|
+
if (path === null) {
|
|
17
|
+
problems.push({ file: raw, message: `"${raw}" isn't a path inside the project.` });
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const role = roleOf(path);
|
|
21
|
+
if (isProblem(role)) {
|
|
22
|
+
problems.push(role);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (role.kind === "ignored")
|
|
26
|
+
continue;
|
|
27
|
+
roles.set(path, role);
|
|
28
|
+
texts.set(path, text);
|
|
29
|
+
}
|
|
30
|
+
if (!roles.has(PROJECT_FILE))
|
|
31
|
+
problems.push({ file: PROJECT_FILE, message: "There is no tongue.json: a film project starts with one (`tongue init` writes it)." });
|
|
32
|
+
if (!roles.has(FILM_FILE))
|
|
33
|
+
problems.push({ file: FILM_FILE, message: "There is no film.tsx: the timeline, whose default export is <Film>." });
|
|
34
|
+
const sceneIds = [...roles.values()].flatMap((r) => (r.kind === "scene" ? [r.id] : [])).sort();
|
|
35
|
+
if (sceneIds.length === 0)
|
|
36
|
+
problems.push({ file: "scenes", message: "There are no scenes: add scenes/<id>.tsx, each exporting scene({ name: \"<id>\", … })." });
|
|
37
|
+
if (problems.length > 0)
|
|
38
|
+
return yield* layoutError(problems);
|
|
39
|
+
const manifestText = texts.get(PROJECT_FILE) ?? "";
|
|
40
|
+
const manifestJson = yield* Effect.try({ try: () => JSON.parse(manifestText), catch: (e) => new CompileError({ file: PROJECT_FILE, line: null, message: `tongue.json isn't valid JSON: ${e instanceof Error ? e.message : String(e)}` }) });
|
|
41
|
+
const manifest = yield* decodeManifest(manifestJson).pipe(Effect.mapError((e) => layoutError([{ file: PROJECT_FILE, message: `tongue.json: ${e.message}` }])));
|
|
42
|
+
const modules = new Map();
|
|
43
|
+
for (const [path, role] of [...roles].sort(([a], [b]) => byText(a, b))) {
|
|
44
|
+
if (role.kind === "project")
|
|
45
|
+
continue;
|
|
46
|
+
const compiled = yield* Effect.try({
|
|
47
|
+
try: () => compileModule(path, texts.get(path) ?? "", roles),
|
|
48
|
+
catch: (e) => (e instanceof CompileError ? e : new CompileError({ file: path, line: null, message: e instanceof Error ? e.message : String(e) })),
|
|
49
|
+
});
|
|
50
|
+
problems.push(...compiled.problems);
|
|
51
|
+
modules.set(path, compiled.module);
|
|
52
|
+
}
|
|
53
|
+
if (problems.length > 0)
|
|
54
|
+
return yield* layoutError(problems);
|
|
55
|
+
const scenes = new Map();
|
|
56
|
+
for (const id of sceneIds) {
|
|
57
|
+
scenes.set(id, yield* Effect.promise(() => bundleOf(`scenes/${id}.tsx`, modules, { dir: "scenes/", stem: id, describe: `scene "${id}"` })));
|
|
58
|
+
}
|
|
59
|
+
const film = yield* Effect.promise(() => bundleOf(FILM_FILE, modules, { dir: "", stem: "film", describe: "the timeline" }));
|
|
60
|
+
const source = yield* Effect.promise(() => sha256([...texts].sort(([a], [b]) => byText(a, b)).map(([path, text]) => `${path}\0${text}`).join("\0")));
|
|
61
|
+
const compiled = { manifest, modules, scenes, film, source };
|
|
62
|
+
return compiled;
|
|
63
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Problem } from "../errors.js";
|
|
2
|
+
import { type FileRole } from "./layout.js";
|
|
3
|
+
/** What the film runtime is linked as: the two modules compiled code imports. */
|
|
4
|
+
export declare const RUNTIME_MODULES: ReadonlyArray<string>;
|
|
5
|
+
/** What an import of a module links to. */
|
|
6
|
+
export type Link = {
|
|
7
|
+
readonly kind: "module";
|
|
8
|
+
readonly path: string;
|
|
9
|
+
} | {
|
|
10
|
+
readonly kind: "runtime";
|
|
11
|
+
readonly name: string;
|
|
12
|
+
} | {
|
|
13
|
+
readonly kind: "scene";
|
|
14
|
+
readonly id: string;
|
|
15
|
+
};
|
|
16
|
+
export interface CompiledModule {
|
|
17
|
+
readonly path: string;
|
|
18
|
+
/** The module as a CommonJS function body: `module`, `exports` and `require` are its arguments. */
|
|
19
|
+
readonly code: string;
|
|
20
|
+
/** Each specifier it requires, and what that links to. */
|
|
21
|
+
readonly links: ReadonlyMap<string, Link>;
|
|
22
|
+
}
|
|
23
|
+
/** Compiles one module and resolves its imports; problems with imports come back as Problems. */
|
|
24
|
+
export declare function compileModule(path: string, text: string, roles: ReadonlyMap<string, FileRole>): {
|
|
25
|
+
readonly module: CompiledModule;
|
|
26
|
+
readonly problems: ReadonlyArray<Problem>;
|
|
27
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { transform } from "sucrase";
|
|
2
|
+
import { CompileError } from "../errors.js";
|
|
3
|
+
import { FILM_FILE } from "./layout.js";
|
|
4
|
+
// Compiling one module: TypeScript and JSX to plain JavaScript, with JSX going to @tongue-core/film's
|
|
5
|
+
// development runtime (so every element knows its file and line) and imports turned into
|
|
6
|
+
// `require` calls the bundle's own loader answers. Each import is resolved here, against the
|
|
7
|
+
// project's files, to what the bundle links: another module, the film runtime, or a scene.
|
|
8
|
+
/** What the film runtime is linked as: the two modules compiled code imports. */
|
|
9
|
+
export const RUNTIME_MODULES = ["@tongue-core/film", "@tongue-core/film/jsx-dev-runtime"];
|
|
10
|
+
const EXTENSIONS = [".tsx", ".ts", ".json"];
|
|
11
|
+
/** The project file a relative import means: exact, then with each extension, then a folder's index. */
|
|
12
|
+
function resolveRelative(from, specifier, files) {
|
|
13
|
+
const parts = [...from.split("/").slice(0, -1), ...specifier.split("/")];
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const part of parts) {
|
|
16
|
+
if (part === "" || part === ".")
|
|
17
|
+
continue;
|
|
18
|
+
if (part === "..") {
|
|
19
|
+
if (out.length === 0)
|
|
20
|
+
return null;
|
|
21
|
+
out.pop();
|
|
22
|
+
}
|
|
23
|
+
else
|
|
24
|
+
out.push(part);
|
|
25
|
+
}
|
|
26
|
+
const base = out.join("/");
|
|
27
|
+
const candidates = [base, ...EXTENSIONS.map((e) => base + e), ...EXTENSIONS.map((e) => `${base}/index${e}`)];
|
|
28
|
+
// An import written for the compiled name ("./brand.js") still finds "brand.ts".
|
|
29
|
+
if (base.endsWith(".js"))
|
|
30
|
+
candidates.push(base.replace(/\.js$/, ".ts"), base.replace(/\.js$/, ".tsx"));
|
|
31
|
+
return candidates.find((c) => files.has(c)) ?? null;
|
|
32
|
+
}
|
|
33
|
+
// Sucrase writes every import (static, re-export or dynamic) as `require('…')`. A string in the
|
|
34
|
+
// film's own code that looks exactly like one would be read as an import too.
|
|
35
|
+
const REQUIRE = /\brequire\((['"])([^'"\\\n]+)\1\)/g;
|
|
36
|
+
function lineOf(message) {
|
|
37
|
+
const m = /\((\d+):\d+\)/.exec(message);
|
|
38
|
+
return m ? Number(m[1]) : null;
|
|
39
|
+
}
|
|
40
|
+
/** Compiles one module and resolves its imports; problems with imports come back as Problems. */
|
|
41
|
+
export function compileModule(path, text, roles) {
|
|
42
|
+
if (path.endsWith(".json")) {
|
|
43
|
+
let value;
|
|
44
|
+
try {
|
|
45
|
+
value = JSON.parse(text);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
throw new CompileError({ file: path, line: null, message: `${path} isn't valid JSON: ${e instanceof Error ? e.message : String(e)}` });
|
|
49
|
+
}
|
|
50
|
+
return { module: { path, code: `module.exports = ${JSON.stringify(value)};`, links: new Map() }, problems: [] };
|
|
51
|
+
}
|
|
52
|
+
let code;
|
|
53
|
+
try {
|
|
54
|
+
code = transform(text, {
|
|
55
|
+
transforms: path.endsWith(".tsx") ? ["typescript", "jsx", "imports"] : ["typescript", "imports"],
|
|
56
|
+
jsxRuntime: "automatic",
|
|
57
|
+
jsxImportSource: "@tongue-core/film",
|
|
58
|
+
production: false,
|
|
59
|
+
filePath: path,
|
|
60
|
+
}).code;
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
64
|
+
throw new CompileError({ file: path, line: lineOf(message), message: `${path} doesn't compile: ${message}` });
|
|
65
|
+
}
|
|
66
|
+
const files = new Set(roles.keys());
|
|
67
|
+
const links = new Map();
|
|
68
|
+
const problems = [];
|
|
69
|
+
const fromFilm = path === FILM_FILE;
|
|
70
|
+
for (const match of code.matchAll(REQUIRE)) {
|
|
71
|
+
const specifier = match[2] ?? "";
|
|
72
|
+
if (links.has(specifier))
|
|
73
|
+
continue;
|
|
74
|
+
if (RUNTIME_MODULES.includes(specifier)) {
|
|
75
|
+
links.set(specifier, { kind: "runtime", name: specifier });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!specifier.startsWith(".")) {
|
|
79
|
+
problems.push({ file: path, message: `A film project imports only "@tongue-core/film" and its own files, not "${specifier}".` });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const target = resolveRelative(path, specifier, files);
|
|
83
|
+
const role = target === null ? undefined : roles.get(target);
|
|
84
|
+
if (target === null || role === undefined) {
|
|
85
|
+
problems.push({ file: path, message: `Cannot find "${specifier}" (imported by ${path}).` });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
switch (role.kind) {
|
|
89
|
+
case "scene":
|
|
90
|
+
if (fromFilm)
|
|
91
|
+
links.set(specifier, { kind: "scene", id: role.id });
|
|
92
|
+
else
|
|
93
|
+
problems.push({ file: path, message: `Only film.tsx imports scenes (${path} imports ${target}); share code between scenes through kit/ or lib/.` });
|
|
94
|
+
break;
|
|
95
|
+
case "film":
|
|
96
|
+
problems.push({ file: path, message: `Nothing imports film.tsx (${path} does): it is the timeline, the top of the project.` });
|
|
97
|
+
break;
|
|
98
|
+
case "project":
|
|
99
|
+
case "ignored":
|
|
100
|
+
problems.push({ file: path, message: `${target} isn't a module a film can import.` });
|
|
101
|
+
break;
|
|
102
|
+
case "module":
|
|
103
|
+
links.set(specifier, { kind: "module", path: target });
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { module: { path, code, links }, problems };
|
|
108
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { type Json, type MediaInfo } from "@tongue-core/film";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { TimelineError } from "../errors.js";
|
|
4
|
+
import { type SceneDoc } from "../doc/film-doc.js";
|
|
5
|
+
import type { LoadedScene } from "../load/scene.js";
|
|
6
|
+
import type { LoadedProject } from "./load-project.js";
|
|
7
|
+
export interface ExtractOptions {
|
|
8
|
+
/** Media lengths, for clips that play a file to its end (what the host's import found). */
|
|
9
|
+
readonly media?: MediaInfo;
|
|
10
|
+
}
|
|
11
|
+
/** A scene's entry in film.json. */
|
|
12
|
+
export declare function sceneDoc(s: LoadedScene): SceneDoc;
|
|
13
|
+
/** Where two JSON values first differ, as a path (null when they are the same). */
|
|
14
|
+
export declare function firstDifference(a: Json, b: Json, path?: string): string | null;
|
|
15
|
+
export declare const extractFilm: (project: LoadedProject, options?: ExtractOptions | undefined) => Effect.Effect<{
|
|
16
|
+
readonly format: "tongue.film";
|
|
17
|
+
readonly version: 1;
|
|
18
|
+
readonly film: {
|
|
19
|
+
readonly width: number;
|
|
20
|
+
readonly height: number;
|
|
21
|
+
readonly fps: number;
|
|
22
|
+
readonly background: string;
|
|
23
|
+
readonly tail: number;
|
|
24
|
+
readonly length: number;
|
|
25
|
+
};
|
|
26
|
+
readonly kit: import("effect/Schema").Json;
|
|
27
|
+
readonly kitSchema: import("effect/Schema").Json;
|
|
28
|
+
readonly scenes: readonly {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly file: string;
|
|
31
|
+
readonly bundle: string;
|
|
32
|
+
readonly hash: string;
|
|
33
|
+
readonly keys: readonly string[];
|
|
34
|
+
readonly kit: boolean;
|
|
35
|
+
readonly assets: readonly {
|
|
36
|
+
readonly kind: "font" | "image" | "video" | "audio";
|
|
37
|
+
readonly name: string;
|
|
38
|
+
}[];
|
|
39
|
+
readonly props: import("effect/Schema").Json;
|
|
40
|
+
}[];
|
|
41
|
+
readonly assets: readonly ({
|
|
42
|
+
readonly kind: "font";
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly source: string;
|
|
45
|
+
readonly sha256?: string | undefined;
|
|
46
|
+
readonly prepared?: string | undefined;
|
|
47
|
+
readonly italic?: string | undefined;
|
|
48
|
+
} | {
|
|
49
|
+
readonly kind: "image";
|
|
50
|
+
readonly name: string;
|
|
51
|
+
readonly source: string;
|
|
52
|
+
readonly maxSize: number;
|
|
53
|
+
readonly sha256?: string | undefined;
|
|
54
|
+
readonly prepared?: string | undefined;
|
|
55
|
+
} | {
|
|
56
|
+
readonly kind: "video";
|
|
57
|
+
readonly name: string;
|
|
58
|
+
readonly source: string;
|
|
59
|
+
readonly maxSize: number;
|
|
60
|
+
readonly sha256?: string | undefined;
|
|
61
|
+
readonly prepared?: string | undefined;
|
|
62
|
+
} | {
|
|
63
|
+
readonly kind: "audio";
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly source: string;
|
|
66
|
+
readonly sha256?: string | undefined;
|
|
67
|
+
readonly prepared?: string | undefined;
|
|
68
|
+
})[];
|
|
69
|
+
readonly tracks: readonly {
|
|
70
|
+
readonly id: string;
|
|
71
|
+
readonly kind: "audio" | "visual";
|
|
72
|
+
readonly clips: readonly ({
|
|
73
|
+
readonly kind: "scene";
|
|
74
|
+
readonly id: string;
|
|
75
|
+
readonly props: import("effect/Schema").Json;
|
|
76
|
+
readonly scene: string;
|
|
77
|
+
readonly opacity: number;
|
|
78
|
+
readonly start: number;
|
|
79
|
+
readonly duration: number;
|
|
80
|
+
readonly end: number;
|
|
81
|
+
readonly origin: {
|
|
82
|
+
readonly file: string;
|
|
83
|
+
readonly line: number;
|
|
84
|
+
} | null;
|
|
85
|
+
readonly blend?: "multiply" | "screen" | "overlay" | "soft-light" | "color-dodge" | "normal" | undefined;
|
|
86
|
+
} | {
|
|
87
|
+
readonly in: number;
|
|
88
|
+
readonly out: number;
|
|
89
|
+
readonly kind: "video";
|
|
90
|
+
readonly id: string;
|
|
91
|
+
readonly opacity: number;
|
|
92
|
+
readonly start: number;
|
|
93
|
+
readonly duration: number;
|
|
94
|
+
readonly end: number;
|
|
95
|
+
readonly origin: {
|
|
96
|
+
readonly file: string;
|
|
97
|
+
readonly line: number;
|
|
98
|
+
} | null;
|
|
99
|
+
readonly asset: string;
|
|
100
|
+
readonly speed: number;
|
|
101
|
+
readonly reverse: boolean;
|
|
102
|
+
readonly freeze: boolean;
|
|
103
|
+
readonly fit: "cover" | "contain";
|
|
104
|
+
readonly blend?: "multiply" | "screen" | "overlay" | "soft-light" | "color-dodge" | "normal" | undefined;
|
|
105
|
+
} | {
|
|
106
|
+
readonly kind: "image";
|
|
107
|
+
readonly id: string;
|
|
108
|
+
readonly opacity: number;
|
|
109
|
+
readonly start: number;
|
|
110
|
+
readonly duration: number;
|
|
111
|
+
readonly end: number;
|
|
112
|
+
readonly origin: {
|
|
113
|
+
readonly file: string;
|
|
114
|
+
readonly line: number;
|
|
115
|
+
} | null;
|
|
116
|
+
readonly asset: string;
|
|
117
|
+
readonly fit: "cover" | "contain";
|
|
118
|
+
readonly blend?: "multiply" | "screen" | "overlay" | "soft-light" | "color-dodge" | "normal" | undefined;
|
|
119
|
+
} | {
|
|
120
|
+
readonly in: number;
|
|
121
|
+
readonly out: number;
|
|
122
|
+
readonly kind: "audio";
|
|
123
|
+
readonly id: string;
|
|
124
|
+
readonly start: number;
|
|
125
|
+
readonly duration: number;
|
|
126
|
+
readonly end: number;
|
|
127
|
+
readonly origin: {
|
|
128
|
+
readonly file: string;
|
|
129
|
+
readonly line: number;
|
|
130
|
+
} | null;
|
|
131
|
+
readonly asset: string;
|
|
132
|
+
readonly speed: number;
|
|
133
|
+
readonly gain: number;
|
|
134
|
+
readonly fadeIn: number;
|
|
135
|
+
readonly fadeOut: number;
|
|
136
|
+
readonly voice?: {
|
|
137
|
+
readonly words: readonly {
|
|
138
|
+
readonly text: string;
|
|
139
|
+
readonly start: number;
|
|
140
|
+
readonly end: number;
|
|
141
|
+
}[];
|
|
142
|
+
} | undefined;
|
|
143
|
+
})[];
|
|
144
|
+
readonly transitions: readonly ({
|
|
145
|
+
readonly from: string;
|
|
146
|
+
readonly to: string;
|
|
147
|
+
readonly frames: number;
|
|
148
|
+
readonly ease: "linear" | "glide" | "settle" | "snap" | "in" | "out" | "swift";
|
|
149
|
+
readonly start: number;
|
|
150
|
+
readonly end: number;
|
|
151
|
+
readonly origin: {
|
|
152
|
+
readonly file: string;
|
|
153
|
+
readonly line: number;
|
|
154
|
+
} | null;
|
|
155
|
+
readonly style: "fade";
|
|
156
|
+
} | {
|
|
157
|
+
readonly direction: "left" | "right" | "up" | "down";
|
|
158
|
+
readonly from: string;
|
|
159
|
+
readonly to: string;
|
|
160
|
+
readonly frames: number;
|
|
161
|
+
readonly ease: "linear" | "glide" | "settle" | "snap" | "in" | "out" | "swift";
|
|
162
|
+
readonly start: number;
|
|
163
|
+
readonly end: number;
|
|
164
|
+
readonly origin: {
|
|
165
|
+
readonly file: string;
|
|
166
|
+
readonly line: number;
|
|
167
|
+
} | null;
|
|
168
|
+
readonly style: "wipe" | "push";
|
|
169
|
+
} | {
|
|
170
|
+
readonly others: {
|
|
171
|
+
readonly enter: "iris" | "fade" | "rise" | "pop" | "none" | {
|
|
172
|
+
readonly kind: "enter";
|
|
173
|
+
readonly from: {
|
|
174
|
+
readonly opacity?: number | undefined;
|
|
175
|
+
readonly x?: number | undefined;
|
|
176
|
+
readonly y?: number | undefined;
|
|
177
|
+
readonly scale?: number | undefined;
|
|
178
|
+
readonly rotate?: number | undefined;
|
|
179
|
+
readonly turn?: number | undefined;
|
|
180
|
+
readonly tilt?: number | undefined;
|
|
181
|
+
readonly blur?: number | undefined;
|
|
182
|
+
readonly brightness?: number | undefined;
|
|
183
|
+
readonly iris?: number | undefined;
|
|
184
|
+
};
|
|
185
|
+
readonly frames?: number | undefined;
|
|
186
|
+
readonly ease?: "linear" | "glide" | "settle" | "snap" | "in" | "out" | "swift" | undefined;
|
|
187
|
+
readonly spring?: {
|
|
188
|
+
readonly stiffness: number;
|
|
189
|
+
readonly damping: number;
|
|
190
|
+
readonly mass?: number | undefined;
|
|
191
|
+
} | undefined;
|
|
192
|
+
};
|
|
193
|
+
readonly exit: "fade" | "none" | "drop" | "lift" | "recede" | {
|
|
194
|
+
readonly kind: "exit";
|
|
195
|
+
readonly to: {
|
|
196
|
+
readonly opacity?: number | undefined;
|
|
197
|
+
readonly x?: number | undefined;
|
|
198
|
+
readonly y?: number | undefined;
|
|
199
|
+
readonly scale?: number | undefined;
|
|
200
|
+
readonly rotate?: number | undefined;
|
|
201
|
+
readonly turn?: number | undefined;
|
|
202
|
+
readonly tilt?: number | undefined;
|
|
203
|
+
readonly blur?: number | undefined;
|
|
204
|
+
readonly brightness?: number | undefined;
|
|
205
|
+
readonly iris?: number | undefined;
|
|
206
|
+
};
|
|
207
|
+
readonly frames?: number | undefined;
|
|
208
|
+
readonly ease?: "linear" | "glide" | "settle" | "snap" | "in" | "out" | "swift" | undefined;
|
|
209
|
+
};
|
|
210
|
+
readonly stagger: number;
|
|
211
|
+
};
|
|
212
|
+
readonly sharedKeys: readonly string[] | null;
|
|
213
|
+
readonly from: string;
|
|
214
|
+
readonly to: string;
|
|
215
|
+
readonly frames: number;
|
|
216
|
+
readonly ease: "linear" | "glide" | "settle" | "snap" | "in" | "out" | "swift";
|
|
217
|
+
readonly start: number;
|
|
218
|
+
readonly end: number;
|
|
219
|
+
readonly origin: {
|
|
220
|
+
readonly file: string;
|
|
221
|
+
readonly line: number;
|
|
222
|
+
} | null;
|
|
223
|
+
readonly style: "morph";
|
|
224
|
+
} | {
|
|
225
|
+
readonly noise: "cloud" | "ridged";
|
|
226
|
+
readonly size: number;
|
|
227
|
+
readonly edge: string | null;
|
|
228
|
+
readonly from: string;
|
|
229
|
+
readonly to: string;
|
|
230
|
+
readonly frames: number;
|
|
231
|
+
readonly ease: "linear" | "glide" | "settle" | "snap" | "in" | "out" | "swift";
|
|
232
|
+
readonly start: number;
|
|
233
|
+
readonly end: number;
|
|
234
|
+
readonly origin: {
|
|
235
|
+
readonly file: string;
|
|
236
|
+
readonly line: number;
|
|
237
|
+
} | null;
|
|
238
|
+
readonly style: "dissolve";
|
|
239
|
+
})[];
|
|
240
|
+
}[];
|
|
241
|
+
readonly warnings: readonly string[];
|
|
242
|
+
}, TimelineError | import("../errors.js").DocError, never>;
|