@tongue-core/sdk 0.0.0-stage → 0.1.0-rc.2

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -2
  3. package/dist/backends.d.ts +24 -0
  4. package/dist/backends.js +17 -0
  5. package/dist/build/build.d.ts +48 -0
  6. package/dist/build/build.js +162 -0
  7. package/dist/check.d.ts +18 -0
  8. package/dist/check.js +17 -0
  9. package/dist/client.d.ts +43 -0
  10. package/dist/client.js +38 -0
  11. package/dist/encode/encoders.d.ts +68 -0
  12. package/dist/encode/encoders.js +158 -0
  13. package/dist/errors.d.ts +53 -0
  14. package/dist/errors.js +69 -0
  15. package/dist/guards.d.ts +1 -0
  16. package/dist/guards.js +3 -0
  17. package/dist/import/convert.d.ts +12 -0
  18. package/dist/import/convert.js +33 -0
  19. package/dist/import/importer.d.ts +11 -0
  20. package/dist/import/importer.js +94 -0
  21. package/dist/import/media.d.ts +52 -0
  22. package/dist/import/media.js +127 -0
  23. package/dist/index.d.ts +16 -0
  24. package/dist/index.js +19 -0
  25. package/dist/init.d.ts +30 -0
  26. package/dist/init.js +44 -0
  27. package/dist/project.d.ts +47 -0
  28. package/dist/project.js +101 -0
  29. package/dist/raster/direct.d.ts +10 -0
  30. package/dist/raster/direct.js +3 -0
  31. package/dist/raster/gpu.d.ts +33 -0
  32. package/dist/raster/gpu.js +112 -0
  33. package/dist/raster/index.d.ts +4 -0
  34. package/dist/raster/index.js +7 -0
  35. package/dist/raster/library.d.ts +9 -0
  36. package/dist/raster/library.js +47 -0
  37. package/dist/raster/native.d.ts +37 -0
  38. package/dist/raster/native.js +128 -0
  39. package/dist/raster/rasters.d.ts +20 -0
  40. package/dist/raster/rasters.js +37 -0
  41. package/dist/render/film-loader.d.ts +20 -0
  42. package/dist/render/film-loader.js +107 -0
  43. package/dist/render/frame.d.ts +5 -0
  44. package/dist/render/frame.js +11 -0
  45. package/dist/render/pipeline.d.ts +58 -0
  46. package/dist/render/pipeline.js +150 -0
  47. package/dist/render/protocol.d.ts +70 -0
  48. package/dist/render/protocol.js +115 -0
  49. package/dist/render/soundtrack-process.d.ts +1 -0
  50. package/dist/render/soundtrack-process.js +27 -0
  51. package/dist/render/stills.d.ts +29 -0
  52. package/dist/render/stills.js +66 -0
  53. package/dist/render/video-frames.d.ts +20 -0
  54. package/dist/render/video-frames.js +101 -0
  55. package/dist/render/worker.d.ts +1 -0
  56. package/dist/render/worker.js +72 -0
  57. package/dist/studio/server.d.ts +17 -0
  58. package/dist/studio/server.js +155 -0
  59. package/dist/tools.d.ts +21 -0
  60. package/dist/tools.js +42 -0
  61. package/dist/version.d.ts +2 -0
  62. package/dist/version.js +7 -0
  63. package/package.json +50 -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,156 @@
1
- # Temporary Holding Version
1
+ # @tongue-core/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Everything you do with a Tongue film, as a library: import its assets, plan and check it, draw
4
+ frames, render video, open it in the studio, and list what the machine can render with. The
5
+ `tongue` CLI is a thin shell over this package, so anything the CLI does an application can do
6
+ here, with the same results. Built with Effect v4 (orchestration only: the per-frame path is plain
7
+ TypeScript); a Promise facade is included for applications that don't use Effect.
8
+
9
+ ```bash
10
+ bun add @tongue-core/sdk@next # Bun ≥ 1.3; ffmpeg and ffprobe on the PATH
11
+ ```
12
+
13
+ ## Promise API
14
+
15
+ ```ts
16
+ import { createTongue } from "@tongue-core/sdk";
17
+
18
+ const tongue = createTongue();
19
+ await tongue.init("my-film", { template: "social-post" }); // a film project from a template
20
+ const { build } = await tongue.build("my-film"); // my-film/dist: film.json, build.json, bundles
21
+ await tongue.stills("my-film", { frames: [0, 30], outDir: "out/hook", scene: { id: "hook" } }); // one scene alone
22
+ const { text, summary } = await tongue.info("src/film.tsx"); // the timeline, as text and JSON
23
+ const report = await tongue.check("src/film.tsx"); // { ok, problems, warnings }: never rejects for a broken film
24
+ await tongue.sheet("src/film.tsx", { frames: [0, 90, 300], outDir: "out/look" });
25
+ const video = await tongue.render("src/film.tsx", { out: "out/film.mp4", from: 90, to: 300, scale: 0.5 });
26
+ const machine = await tongue.backends(); // rasters and encoders that work here
27
+ ```
28
+
29
+ Failures reject with the SDK's tagged errors (below).
30
+
31
+ ## Effect API
32
+
33
+ ```ts
34
+ import { BunServices } from "@effect/platform-bun";
35
+ import { openFilm, renderVideo } from "@tongue-core/sdk";
36
+ import { Effect } from "effect";
37
+
38
+ const program = Effect.gen(function* () {
39
+ const film = yield* openFilm("src/film.tsx"); // load, import assets, plan
40
+ return yield* renderVideo(film, { out: "out/film.mp4", encoder: "auto", raster: "auto" });
41
+ });
42
+ await Effect.runPromise(program.pipe(Effect.provide(BunServices.layer)));
43
+ ```
44
+
45
+ | Function | Does |
46
+ |---|---|
47
+ | `initProject(dir, { template, name })` | a film project in a new or empty folder, from a built-in template or a template folder |
48
+ | `listTemplates()` / `readTemplate(nameOrPath)` | the built-in templates, or one by name or path |
49
+ | `buildFilm(project, { out })` | builds a film project into `out` (default `dist/`): build.json, film.json, a bundle per scene |
50
+ | `openFilm(path, { scene })` | a film ready to render: a project is built (into `.tongue/builds/<id>`, named by its content) and planned from film.json, a film module is imported and planned; assets imported (cached by size, mtime and options) either way. `scene: { id, props }` opens one scene of a project on its own |
51
+ | `describeFilm(film)` | the plan as text (for agents to read) and as a JSON summary |
52
+ | `checkFilm(path)` | problems and warnings as data |
53
+ | `renderStills(film, { frames, scale, outDir })` / `renderSheet(…)` | PNGs drawn by the wasm raster (the player's), or tiled into one labelled sheet |
54
+ | `renderVideo(film, options)` | an MP4 with sound: the whole film or `[from, to)`, at any scale; `onStart` once the request is checked and the raster and encoder are chosen, `onProgress` while it draws. A frame that throws fails the render with `Frame <n>: <message>` |
55
+ | `listBackends()` | every raster and encoder, each tried for real |
56
+ | `studioServer(path, { port, remote })` | the studio on a project (rebuilt and reloaded on every edit) or on a build served anywhere |
57
+
58
+ ## Films and projects
59
+
60
+ A **film project** is a folder with a `tongue.json` ([docs/film-json.md](../../docs/film-json.md)):
61
+ `film.tsx`, `scenes/`, `kit/`, `lib/`, `assets.ts`, `assets/`. It needs nothing installed: the SDK
62
+ reads its files, compiles and bundles it with `@tongue-core/project`, loads the bundles with its own
63
+ `@tongue-core/film`, imports the assets they declare (ffprobe gives media lengths), extracts film.json
64
+ and writes the build. Everything renders from the build: in this process, in the render workers
65
+ (they open build.json and the bundles), and in the studio (`tongue studio` serves the build; the
66
+ browser fetches it). `.tongue/types/film` links to the SDK's `@tongue-core/film`, which the
67
+ project's `tsconfig.json` points editors at.
68
+
69
+ A **film module** (the way films were written before projects) is a `film.tsx` in a folder with a
70
+ `package.json`. The SDK bundles it with Bun (into `.tongue/modules/`, JSX compiled for Tongue) against
71
+ its own `@tongue-core/film`, the copy the engine reads, as it links a project's bundles, then plans
72
+ it. The folder may install `@tongue-core/film` too, for its editor's types
73
+ (`"jsx": "react-jsx", "jsxImportSource": "@tongue-core/film"` in its tsconfig); that copy isn't the one
74
+ that runs.
75
+
76
+ Either way, asset paths are relative to the root, Tongue writes `.tongue/` there (imported assets,
77
+ builds, render work) and renders to `out/`.
78
+
79
+ A film's assets are what its modules declare (`font()`, `image()`, `audio()`, `video()`) while
80
+ the SDK loads it, plus the media its clips play; `<Film assets={[…]}>` replaces the declared ones.
81
+ Importing turns them into what the engine reads: TrueType fonts, QOI images, WAV audio, video facts
82
+ and a poster frame.
83
+
84
+ ## How a video renders
85
+
86
+ ```
87
+ coordinator ──segments──▶ worker 1: frames ─▶ raster ─▶ encoder ─▶ seg-0000.mp4
88
+ ◀─progress── worker 2 … seg-0001.mp4
89
+ soundtrack: sounds + audio clips ─▶ mix ─▶ AAC audio.m4a
90
+ join: concat + audio ─(stream copy)─▶ film.mp4, frame count probed
91
+ ```
92
+
93
+ - **Workers** are processes (`src/render/worker.ts`), one per physical core by default. Each loads
94
+ the film and its raster once and renders every segment it is sent into that segment's own
95
+ encoder, so the JIT stays warm and glyphs and images stay uploaded.
96
+ - **Segments** are one to two seconds of frames, shrinking towards the end so the last ones finish
97
+ together. Each is a closed-GOP encode; the join copies streams without re-encoding.
98
+ - **Video clips** are decoded by ffmpeg in order, frame after frame, and restarted on jumps.
99
+ - **The soundtrack** is mixed in its own process while the workers draw (`soundtrack-process.ts`).
100
+
101
+ Rasters (who draws) and encoders (who encodes) are chosen by name, because the choice is made here
102
+ but runs inside the workers:
103
+
104
+ | Raster | |
105
+ |---|---|
106
+ | `native` | the Rust raster built for x86-64-v3 (`@tongue-core/raster-linux-x64`); byte-identical to wasm, 2–3× faster |
107
+ | `wasm` | the engine's embedded WebAssembly raster, the browser player's; runs anywhere |
108
+ | `gpu` | EGL + GLES 3.1; within a few levels of the CPU rasters, never chosen by `auto` |
109
+
110
+ Encoders: `x264` (veryfast, crf 14), `vaapi-hevc` / `vaapi-h264` (constant QP 18 / 16, with
111
+ CPU-conversion variants), and `nvenc-*` / `qsv-*` (written from vendor documentation, untested).
112
+ `auto` takes the first that works of nvenc-hevc, vaapi-hevc, vaapi-hevc-cpuconv, qsv-hevc, x264.
113
+
114
+ On this machine (16 threads, AMD GPU) the 35.5 s demo renders at 1080p with sound in about 12 s
115
+ (≈ 87 fps) with `native` + `vaapi-hevc`.
116
+
117
+ ## Errors
118
+
119
+ Every failure is a `Schema.TaggedError` with a message that says what to change. `exitCodeOf`
120
+ gives the CLI's exit code.
121
+
122
+ | Error | When | Exit |
123
+ |---|---|---|
124
+ | `FilmFailure` | the module is missing, throws, isn't a `<Film>`, or its timeline doesn't plan | 2 |
125
+ | `AssetFailure` | an asset's file is missing or unreadable | 2 |
126
+ | `InvalidRequest` | a frame outside the film, a bad range or scale | 2 |
127
+ | `ToolFailure` | ffmpeg or ffprobe is missing or failed | 3 |
128
+ | `BackendUnavailable` | the raster or encoder asked for doesn't work here | 3 |
129
+ | `RenderFailure` | a worker died or a frame failed | 3 |
130
+ | `LayoutError`, `CompileError`, `LoadError`, `TimelineError`, `DocError`, `TemplateError` | a film project doesn't build (from `@tongue-core/project`: each names its file, line or path) | 2 |
131
+
132
+ ## Environment
133
+
134
+ | Variable | |
135
+ |---|---|
136
+ | `TONGUE_FFMPEG`, `TONGUE_FFPROBE` | the tools' paths (default: `ffmpeg`, `ffprobe` on the PATH) |
137
+ | `TONGUE_NATIVE_RASTER`, `TONGUE_GPU_RASTER` | a raster library to use instead of `@tongue-core/raster-linux-x64`'s |
138
+ | `TONGUE_VAAPI_DEVICE` | the render node for VAAPI and Quick Sync (default `/dev/dri/renderD128`) |
139
+
140
+ ## Layout
141
+
142
+ ```
143
+ src/
144
+ index.ts, client.ts the Effect API and the Promise facade
145
+ project.ts projects, openFilm and buildFilm
146
+ build/build.ts building a film project on this machine: its files, a module host, the build folder, editor types
147
+ init.ts init from templates
148
+ check.ts, backends.ts checkFilm, listBackends
149
+ import/ asset import: fonts, images, audio, video (ffprobe, posters)
150
+ render/ stills, the video pipeline, its worker and soundtrack processes, video decoding
151
+ encode/encoders.ts the ffmpeg encoders and how each is tried
152
+ raster/ the native and GPU raster bindings (index.ts) and the registry (rasters.ts)
153
+ studio/server.ts the studio's server: the app bundled, a project's build served and rebuilt
154
+ test/ unit tests, end-to-end ones on fixtures/tiny (a film with text, video and sound), and
155
+ projects from every template (project.test.ts: film.json draws the same frames as film.tsx)
156
+ ```
@@ -0,0 +1,24 @@
1
+ import { Effect } from "effect";
2
+ import { type EncoderName } from "./encode/encoders.js";
3
+ import { type RasterName } from "./raster/rasters.js";
4
+ export interface BackendsReport {
5
+ readonly rasters: ReadonlyArray<{
6
+ readonly name: RasterName;
7
+ readonly available: boolean;
8
+ readonly exact: boolean;
9
+ readonly summary: string;
10
+ }>;
11
+ readonly encoders: ReadonlyArray<{
12
+ readonly name: EncoderName;
13
+ readonly available: boolean;
14
+ readonly hardware: boolean;
15
+ readonly tested: boolean;
16
+ readonly summary: string;
17
+ }>;
18
+ /** What `auto` picks here. */
19
+ readonly auto: {
20
+ readonly raster: RasterName;
21
+ readonly encoder: EncoderName | null;
22
+ };
23
+ }
24
+ export declare const listBackends: () => Effect.Effect<BackendsReport, import("effect/Config").ConfigError, never>;
@@ -0,0 +1,17 @@
1
+ import { Effect } from "effect";
2
+ import { AUTO_ENCODERS, DEFAULT_SETTINGS, ENCODER_NAMES, ENCODER_SUMMARIES, encoderSpec, encoderWorks } from "./encode/encoders.js";
3
+ import { autoRaster, RASTERS } from "./raster/rasters.js";
4
+ import { ffmpegPath } from "./tools.js";
5
+ export const listBackends = Effect.fn("listBackends")(function* () {
6
+ const ffmpeg = yield* ffmpegPath;
7
+ const encoders = yield* Effect.forEach(ENCODER_NAMES, (name) => Effect.promise(async () => {
8
+ const spec = encoderSpec(name, DEFAULT_SETTINGS, 30);
9
+ return { name, available: await encoderWorks(ffmpeg, spec), hardware: spec.hardware, tested: spec.tested, summary: ENCODER_SUMMARIES[name] };
10
+ }), { concurrency: "unbounded" });
11
+ const report = {
12
+ rasters: RASTERS.map((r) => ({ name: r.name, available: r.available(), exact: r.exact, summary: r.summary })),
13
+ encoders,
14
+ auto: { raster: autoRaster().name, encoder: AUTO_ENCODERS.find((n) => encoders.some((e) => e.name === n && e.available)) ?? null },
15
+ };
16
+ return report;
17
+ });
@@ -0,0 +1,48 @@
1
+ import type { Asset, MediaInfo, SceneDefinition } from "@tongue-core/film";
2
+ import { type Build, type ModuleHost, type ProjectFiles } from "@tongue-core/project";
3
+ import { Effect, FileSystem, type PlatformError } from "effect";
4
+ import { type ImportReport } from "../import/importer.js";
5
+ import type { Project } from "../project.js";
6
+ /** A project's code and data: every .ts, .tsx and .json file outside hidden folders and the ones
7
+ * tools write. */
8
+ export declare const readProjectFiles: (root: string) => Effect.Effect<ProjectFiles, PlatformError.PlatformError, FileSystem.FileSystem>;
9
+ /** Loads bundles by writing each to `dir` (named by its hash, so written once) and importing it. */
10
+ export declare const fileModuleHost: (dir: string) => ModuleHost;
11
+ /** Points the project's `.tongue/types/film` at this SDK's @tongue-core/film (tsconfig.json reads it). */
12
+ export declare const linkFilmTypes: (root: string) => Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem>;
13
+ export interface BuiltProject {
14
+ readonly build: Build;
15
+ /** Where the build was written. */
16
+ readonly dir: string;
17
+ readonly imported: ImportReport;
18
+ /** The scenes the bundles export, by id: what film.json is assembled with. */
19
+ readonly scenes: ReadonlyMap<string, SceneDefinition>;
20
+ readonly media: MediaInfo;
21
+ }
22
+ /** The imported files a film's assets are (a video is its facts, its poster and its proxy's
23
+ * segments), with their sizes and hashes: what build.json lists. */
24
+ export declare const importedFiles: (project: Project, assets: readonly Asset[]) => Effect.Effect<{
25
+ readonly path: string;
26
+ readonly bytes: number;
27
+ readonly hash: string;
28
+ }[], PlatformError.PlatformError, FileSystem.FileSystem>;
29
+ /** How a written build reaches its assets: copies of the files (a build to publish), or a link to
30
+ * the project's imported assets (a build for Tongue's own use). */
31
+ export type AssetPlacement = {
32
+ readonly kind: "copy";
33
+ readonly from: string;
34
+ } | {
35
+ readonly kind: "link";
36
+ readonly to: string;
37
+ };
38
+ /** Writes a build to `dir`, replacing the build that was there (old bundles and assets included). */
39
+ export declare const writeBuildFiles: (dir: string, build: Build, assets: AssetPlacement) => Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem>;
40
+ /** Keeps a build in `.tongue/builds/<id>`, named by its content and never rewritten, so renders
41
+ * reading one build are never disturbed by the next. Written to a temporary folder first and moved
42
+ * into place, so a build folder is either whole or not there. */
43
+ export declare const keepBuild: (project: Project, build: Build) => Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem>;
44
+ /** Builds a film project into `dir` (what `tongue build` does: dist/ by default), replacing what
45
+ * was there, with copies of its imported assets: a folder any static host can serve. */
46
+ export declare const buildFilmProject: (project: Project, dir: string) => Effect.Effect<BuiltProject, import("../errors.js").AssetFailure | import("../errors.js").ToolFailure | import("@tongue-core/project").LayoutError | import("@tongue-core/project").CompileError | import("@tongue-core/project").LoadError | import("@tongue-core/project").TimelineError | import("@tongue-core/project").DocError | import("effect/Config").ConfigError | PlatformError.PlatformError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | import("effect/Path").Path>;
47
+ /** Builds a film project for Tongue's own use (stills, renders, dev): kept in .tongue/builds. */
48
+ export declare const buildForWork: (project: Project) => Effect.Effect<BuiltProject, import("../errors.js").AssetFailure | import("../errors.js").ToolFailure | import("@tongue-core/project").LayoutError | import("@tongue-core/project").CompileError | import("@tongue-core/project").LoadError | import("@tongue-core/project").TimelineError | import("@tongue-core/project").DocError | import("effect/Config").ConfigError | PlatformError.PlatformError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | import("effect/Path").Path>;
@@ -0,0 +1,162 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { createHash } from "node:crypto";
10
+ import { dirname, join, relative, sep } from "node:path";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+ import { importedFiles as filesOf, videoProxyDir } from "@tongue-core/engine";
13
+ import { compileProject, extractFilm, IGNORED_DIRS, loadProject, sha256, TYPES_LINK, writeBuild, } from "@tongue-core/project";
14
+ import { Effect, FileSystem } from "effect";
15
+ import { importAssets } from "../import/importer.js";
16
+ import { sdkVersion } from "../version.js";
17
+ // Building a film project on this machine: read its code, compile and bundle it, run the bundles,
18
+ // import the assets they declare (media lengths go into film.json), extract film.json and write
19
+ // the build, with the imported asset files listed in build.json (and copied beside it, or linked,
20
+ // so a build folder is everything a player needs). Everything but the files and the module
21
+ // loading is @tongue-core/project's.
22
+ /** A project's code and data: every .ts, .tsx and .json file outside hidden folders and the ones
23
+ * tools write. */
24
+ export const readProjectFiles = Effect.fn("readProjectFiles")(function* (root) {
25
+ const fs = yield* FileSystem.FileSystem;
26
+ const files = new Map();
27
+ const walk = (dir) => Effect.gen(function* () {
28
+ for (const name of (yield* fs.readDirectory(dir)).sort()) {
29
+ const path = join(dir, name);
30
+ const info = yield* fs.stat(path);
31
+ if (info.type === "Directory") {
32
+ // What tools write, and anything hidden (.git, .vscode): never the film's.
33
+ if (!IGNORED_DIRS.has(name) && !name.startsWith("."))
34
+ yield* walk(path);
35
+ }
36
+ else if (/\.(ts|tsx|json)$/.test(name)) {
37
+ files.set(relative(root, path).split(sep).join("/"), yield* fs.readFileString(path));
38
+ }
39
+ }
40
+ });
41
+ yield* walk(root);
42
+ const out = files;
43
+ return out;
44
+ });
45
+ /** Loads bundles by writing each to `dir` (named by its hash, so written once) and importing it. */
46
+ export const fileModuleHost = (dir) => ({
47
+ load: async (bundle) => {
48
+ const file = join(dir, bundle.path);
49
+ if (!(await Bun.file(file).exists()))
50
+ await Bun.write(file, bundle.code);
51
+ const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
52
+ return module;
53
+ },
54
+ });
55
+ /** The folder of the @tongue-core/film this SDK uses: what a project's editor types come from. */
56
+ const filmPackage = dirname(dirname(fileURLToPath(import.meta.resolve("@tongue-core/film"))));
57
+ /** Points the project's `.tongue/types/film` at this SDK's @tongue-core/film (tsconfig.json reads it). */
58
+ export const linkFilmTypes = Effect.fn("linkFilmTypes")(function* (root) {
59
+ const fs = yield* FileSystem.FileSystem;
60
+ const link = join(root, TYPES_LINK);
61
+ const current = yield* fs.readLink(link).pipe(Effect.orElseSucceed(() => null));
62
+ if (current === filmPackage)
63
+ return;
64
+ yield* fs.remove(link, { force: true });
65
+ yield* fs.makeDirectory(dirname(link), { recursive: true });
66
+ yield* fs.symlink(filmPackage, link);
67
+ });
68
+ /** The imported files a film's assets are (a video is its facts, its poster and its proxy's
69
+ * segments), with their sizes and hashes: what build.json lists. */
70
+ export const importedFiles = Effect.fn("importedFiles")(function* (project, assets) {
71
+ const fs = yield* FileSystem.FileSystem;
72
+ const proxies = yield* Effect.forEach(assets.filter((a) => a.kind === "video"), (a) => {
73
+ const dir = videoProxyDir(a);
74
+ return fs.readDirectory(join(project.assetsDir, dir)).pipe(Effect.map((names) => names.filter((n) => n.endsWith(".mp4")).sort().map((n) => `${dir}${n}`)), Effect.orElseSucceed(() => []));
75
+ });
76
+ const paths = [...new Set([...assets.flatMap(filesOf), ...proxies.flat()])];
77
+ return yield* Effect.forEach(paths, (path) => Effect.map(fs.readFile(join(project.assetsDir, path)), (bytes) => ({
78
+ path,
79
+ bytes: bytes.length,
80
+ hash: createHash("sha256").update(bytes).digest("hex"),
81
+ })), { concurrency: 8 });
82
+ });
83
+ /** Writes a build to `dir`, replacing the build that was there (old bundles and assets included). */
84
+ export const writeBuildFiles = Effect.fn("writeBuildFiles")(function* (dir, build, assets) {
85
+ const fs = yield* FileSystem.FileSystem;
86
+ yield* fs.makeDirectory(dir, { recursive: true });
87
+ for (const name of yield* fs.readDirectory(dir)) {
88
+ if (name === "scenes" || name === build.manifest.assets.dir || name.endsWith(".json"))
89
+ yield* fs.remove(join(dir, name), { recursive: true });
90
+ }
91
+ for (const file of build.files) {
92
+ const path = join(dir, file.path);
93
+ yield* fs.makeDirectory(dirname(path), { recursive: true });
94
+ yield* fs.writeFileString(path, file.text);
95
+ }
96
+ const assetsDir = join(dir, build.manifest.assets.dir);
97
+ if (assets.kind === "link") {
98
+ yield* fs.symlink(assets.to, assetsDir);
99
+ return;
100
+ }
101
+ for (const file of build.manifest.assets.files) {
102
+ const path = join(assetsDir, file.path);
103
+ yield* fs.makeDirectory(dirname(path), { recursive: true });
104
+ yield* fs.copyFile(join(assets.from, file.path), path);
105
+ }
106
+ });
107
+ /** Builds kept in .tongue/builds: the newest this many, older ones removed. */
108
+ const KEPT_BUILDS = 8;
109
+ /** Keeps a build in `.tongue/builds/<id>`, named by its content and never rewritten, so renders
110
+ * reading one build are never disturbed by the next. Written to a temporary folder first and moved
111
+ * into place, so a build folder is either whole or not there. */
112
+ export const keepBuild = Effect.fn("keepBuild")(function* (project, build) {
113
+ const fs = yield* FileSystem.FileSystem;
114
+ const builds = join(project.workDir, "builds");
115
+ const id = (yield* Effect.promise(() => sha256(build.files.map((f) => `${f.path}\0${f.text}`).join("\0")))).slice(0, 16);
116
+ const dir = join(builds, id);
117
+ if (yield* fs.exists(join(dir, "build.json")))
118
+ return dir;
119
+ yield* fs.makeDirectory(builds, { recursive: true });
120
+ const temp = yield* fs.makeTempDirectory({ directory: builds, prefix: ".writing-" });
121
+ yield* writeBuildFiles(temp, build, { kind: "link", to: project.assetsDir });
122
+ // Another process may have kept the same build meanwhile: its copy is the same bytes.
123
+ yield* fs.rename(temp, dir).pipe(Effect.catch(() => fs.remove(temp, { recursive: true })));
124
+ yield* pruneBuilds(builds, dir);
125
+ return dir;
126
+ });
127
+ /** Removes all but the newest builds (never `keep`); a build in use elsewhere is old enough to go
128
+ * only after KEPT_BUILDS newer ones. */
129
+ const pruneBuilds = Effect.fn("pruneBuilds")(function* (builds, keep) {
130
+ const fs = yield* FileSystem.FileSystem;
131
+ const dated = yield* Effect.forEach(yield* fs.readDirectory(builds), (name) => fs.stat(join(builds, name)).pipe(Effect.map((info) => ({ dir: join(builds, name), at: info.mtime._tag === "Some" ? info.mtime.value.getTime() : 0, temp: name.startsWith(".") }))));
132
+ const old = dated.filter((b) => !b.temp && b.dir !== keep).sort((a, b) => b.at - a.at).slice(KEPT_BUILDS - 1);
133
+ for (const b of old)
134
+ yield* fs.remove(b.dir, { recursive: true, force: true });
135
+ });
136
+ /** A film project built in memory: compiled, its bundles run, its assets imported (their lengths
137
+ * go into film.json), film.json extracted. */
138
+ const buildInMemory = Effect.fn("buildInMemory")(function* (project) {
139
+ const files = yield* readProjectFiles(project.root);
140
+ const compiled = yield* compileProject(files);
141
+ const loaded = yield* loadProject(compiled, fileModuleHost(join(project.workDir, "modules")));
142
+ const imported = yield* importAssets(project, loaded.assets);
143
+ const media = { duration: (asset) => imported.durations.get(`${asset.kind}:${asset.name}`) ?? null };
144
+ const doc = yield* extractFilm(loaded, { media });
145
+ const build = writeBuild(compiled, doc, { tongue: yield* sdkVersion, assets: yield* importedFiles(project, loaded.assets) });
146
+ yield* linkFilmTypes(project.root);
147
+ return { build, imported, scenes: new Map(loaded.scenes.map((s) => [s.id, s.scene])), media };
148
+ });
149
+ /** Builds a film project into `dir` (what `tongue build` does: dist/ by default), replacing what
150
+ * was there, with copies of its imported assets: a folder any static host can serve. */
151
+ export const buildFilmProject = Effect.fn("buildFilmProject")(function* (project, dir) {
152
+ const built = yield* buildInMemory(project);
153
+ yield* writeBuildFiles(dir, built.build, { kind: "copy", from: project.assetsDir });
154
+ const out = { ...built, dir };
155
+ return out;
156
+ });
157
+ /** Builds a film project for Tongue's own use (stills, renders, dev): kept in .tongue/builds. */
158
+ export const buildForWork = Effect.fn("buildForWork")(function* (project) {
159
+ const built = yield* buildInMemory(project);
160
+ const out = { ...built, dir: yield* keepBuild(project, built.build) };
161
+ return out;
162
+ });
@@ -0,0 +1,18 @@
1
+ import type { PlanSummary } from "@tongue-core/film";
2
+ import { Effect } from "effect";
3
+ export interface CheckProblem {
4
+ /** layout, compile, load, timeline, doc (film.json), asset. */
5
+ readonly kind: string;
6
+ readonly file: string | null;
7
+ readonly message: string;
8
+ }
9
+ export interface CheckReport {
10
+ readonly ok: boolean;
11
+ /** What stops the film from rendering, with what to change. */
12
+ readonly problems: ReadonlyArray<CheckProblem>;
13
+ /** What will render, but probably not as intended. */
14
+ readonly warnings: ReadonlyArray<string>;
15
+ readonly summary: PlanSummary | null;
16
+ readonly text: string | null;
17
+ }
18
+ export declare const checkFilm: (path: string) => Effect.Effect<CheckReport, import("./errors.js").InvalidRequest | import("./errors.js").ToolFailure | import("effect/Config").ConfigError | import("effect/PlatformError").PlatformError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | import("effect/FileSystem").FileSystem | import("effect/Path").Path>;
package/dist/check.js ADDED
@@ -0,0 +1,17 @@
1
+ import { Effect } from "effect";
2
+ import { describeFilm, openFilm } from "./project.js";
3
+ const failed = (problems) => ({ ok: false, problems, warnings: [], summary: null, text: null });
4
+ export const checkFilm = Effect.fn("checkFilm")(function* (path) {
5
+ return yield* openFilm(path).pipe(Effect.map((film) => {
6
+ const { text, summary } = describeFilm(film);
7
+ return { ok: true, problems: [], warnings: film.plan.warnings, summary, text };
8
+ }), Effect.catchTags({
9
+ FilmFailure: (e) => Effect.succeed(failed([{ kind: e.phase === "load" ? "load" : "timeline", file: e.film, message: e.message }])),
10
+ AssetFailure: (e) => Effect.succeed(failed([{ kind: "asset", file: null, message: e.message }])),
11
+ LayoutError: (e) => Effect.succeed(failed(e.problems.map((p) => ({ kind: "layout", file: p.file, message: p.message })))),
12
+ CompileError: (e) => Effect.succeed(failed([{ kind: "compile", file: e.line === null ? e.file : `${e.file}:${e.line}`, message: e.message }])),
13
+ LoadError: (e) => Effect.succeed(failed([{ kind: "load", file: e.file, message: e.message }])),
14
+ TimelineError: (e) => Effect.succeed(failed([{ kind: "timeline", file: null, message: e.message }])),
15
+ DocError: (e) => Effect.succeed(failed([{ kind: "doc", file: "film.json", message: e.message }])),
16
+ }));
17
+ });
@@ -0,0 +1,43 @@
1
+ import { type InitOptions } from "./init.js";
2
+ import { type OpenOptions } from "./project.js";
3
+ import { type VideoOptions } from "./render/pipeline.js";
4
+ import { type StillsOptions } from "./render/stills.js";
5
+ export declare function createTongue(): {
6
+ /** Makes a film project in a new or empty folder from a template. */
7
+ init: (dir: string, options?: InitOptions) => Promise<import("./init.js").InitReport>;
8
+ /** The templates that come with Tongue. */
9
+ templates: () => Promise<readonly {
10
+ readonly width: number;
11
+ readonly height: number;
12
+ readonly name: string;
13
+ readonly fps: number;
14
+ readonly title: string;
15
+ readonly description: string;
16
+ readonly tags?: readonly string[] | undefined;
17
+ }[]>;
18
+ /** Builds a film project: build.json, film.json and a bundle per scene, in its dist folder. */
19
+ build: (project: string, options?: {
20
+ readonly out?: string;
21
+ }) => Promise<import("./index.js").BuiltProject>;
22
+ /** Imports the film's assets and plans it; resolves to its timeline as text and JSON. */
23
+ info: (film: string) => Promise<{
24
+ readonly text: string;
25
+ readonly summary: import("@tongue-core/film").PlanSummary;
26
+ }>;
27
+ /** Problems and warnings as data: never rejects for a broken film. */
28
+ check: (film: string) => Promise<import("./check.js").CheckReport>;
29
+ /** Frames as PNG files. */
30
+ stills: (film: string, options: StillsOptions & OpenOptions) => Promise<import("./index.js").Still[]>;
31
+ /** Frames tiled into one labelled image. */
32
+ sheet: (film: string, options: StillsOptions & OpenOptions & {
33
+ readonly columns?: number;
34
+ }) => Promise<{
35
+ stills: import("./index.js").Still[];
36
+ sheet: string;
37
+ }>;
38
+ /** The film (or a range of it, or one scene of a project) as an MP4. */
39
+ render: (film: string, options: VideoOptions & OpenOptions) => Promise<import("./index.js").VideoResult>;
40
+ /** The rasters and encoders this machine can use. */
41
+ backends: () => Promise<import("./backends.js").BackendsReport>;
42
+ };
43
+ export type Tongue = ReturnType<typeof createTongue>;
package/dist/client.js ADDED
@@ -0,0 +1,38 @@
1
+ import { BunServices } from "@effect/platform-bun";
2
+ import { Cause, Effect, Exit } from "effect";
3
+ import { listBackends } from "./backends.js";
4
+ import { checkFilm } from "./check.js";
5
+ import { initProject, listTemplates } from "./init.js";
6
+ import { buildFilm, describeFilm, openFilm } from "./project.js";
7
+ import { renderVideo } from "./render/pipeline.js";
8
+ import { renderSheet, renderStills } from "./render/stills.js";
9
+ // The SDK as plain Promises, for applications that don't use Effect. Each call runs the same Effect
10
+ // program the CLI runs; a failure rejects with the SDK's tagged error (FilmFailure, ToolFailure…).
11
+ const run = async (effect) => {
12
+ const exit = await Effect.runPromiseExit(effect.pipe(Effect.provide(BunServices.layer)));
13
+ if (Exit.isSuccess(exit))
14
+ return exit.value;
15
+ throw Cause.squash(exit.cause);
16
+ };
17
+ export function createTongue() {
18
+ return {
19
+ /** Makes a film project in a new or empty folder from a template. */
20
+ init: (dir, options) => run(initProject(dir, options)),
21
+ /** The templates that come with Tongue. */
22
+ templates: () => run(listTemplates()),
23
+ /** Builds a film project: build.json, film.json and a bundle per scene, in its dist folder. */
24
+ build: (project, options) => run(buildFilm(project, options)),
25
+ /** Imports the film's assets and plans it; resolves to its timeline as text and JSON. */
26
+ info: (film) => run(openFilm(film).pipe(Effect.map(describeFilm))),
27
+ /** Problems and warnings as data: never rejects for a broken film. */
28
+ check: (film) => run(checkFilm(film)),
29
+ /** Frames as PNG files. */
30
+ stills: (film, options) => run(openFilm(film, options).pipe(Effect.flatMap((f) => renderStills(f, options)))),
31
+ /** Frames tiled into one labelled image. */
32
+ sheet: (film, options) => run(openFilm(film, options).pipe(Effect.flatMap((f) => renderSheet(f, options)))),
33
+ /** The film (or a range of it, or one scene of a project) as an MP4. */
34
+ render: (film, options) => run(openFilm(film, options).pipe(Effect.flatMap((f) => renderVideo(f, options)))),
35
+ /** The rasters and encoders this machine can use. */
36
+ backends: () => run(listBackends()),
37
+ };
38
+ }
@@ -0,0 +1,68 @@
1
+ export type VideoCodec = "h264" | "hevc";
2
+ export interface EncodeTarget {
3
+ readonly width: number;
4
+ readonly height: number;
5
+ readonly fps: number;
6
+ /** The video-only file to write; audio is muxed in afterwards. */
7
+ readonly out: string;
8
+ }
9
+ /** An open encode. A frame's buffer may be reused once the next `write` has resolved. */
10
+ export interface Encoder {
11
+ write(frame: Uint8Array): Promise<void>;
12
+ finish(): Promise<void>;
13
+ }
14
+ export interface EncoderSpec {
15
+ /** With its settings: "x264 veryfast crf 14", "vaapi-hevc qp 18". */
16
+ readonly name: string;
17
+ readonly codec: VideoCodec;
18
+ readonly hardware: boolean;
19
+ /** False for settings written from vendor documentation and never run on a machine of ours. */
20
+ readonly tested: boolean;
21
+ /** Options before the raw input (hardware devices). */
22
+ readonly device: ReadonlyArray<string>;
23
+ /** The filter from the raw input to what the encoder takes (uploads to the GPU). */
24
+ readonly filter: string | null;
25
+ readonly encode: ReadonlyArray<string>;
26
+ /** AMD's HEVC encoder pads the picture and writes no conformance window, or a wrong one, so 1080
27
+ * rows decode as 1088 and 540 columns as 572: the window is set again when muxing. */
28
+ readonly fixCrop: boolean;
29
+ }
30
+ /** Where ffmpeg and ffprobe are. */
31
+ export interface Tools {
32
+ readonly ffmpeg: string;
33
+ readonly ffprobe: string;
34
+ }
35
+ export declare class EncoderError extends Error {
36
+ readonly name = "EncoderError";
37
+ }
38
+ /** Opens an encode: frames go to ffmpeg's stdin, one in flight while the next is drawn. */
39
+ export declare function openEncoder(tools: Tools, spec: EncoderSpec, target: EncodeTarget): Encoder;
40
+ /** Whether this machine can run an encoder: a 0.2 s encode of a test pattern through its arguments,
41
+ * at the size it will encode (hardware encoders have a smallest size; VAAPI's is about 130×128). */
42
+ export declare function encoderWorks(ffmpeg: string, spec: EncoderSpec, size?: {
43
+ readonly width: number;
44
+ readonly height: number;
45
+ }): Promise<boolean>;
46
+ export interface EncoderSettings {
47
+ /** x264: preset and constant rate factor. */
48
+ readonly preset: string;
49
+ readonly crf: number;
50
+ /** x264: threads per encoder (two keeps x264 efficient when many encoders share the machine). */
51
+ readonly threads: number;
52
+ /** Hardware: constant QP (lower is better and bigger); null takes each codec's default. */
53
+ readonly qp: number | null;
54
+ /** Frames between keyframes (default: two seconds). */
55
+ readonly gop: number | null;
56
+ /** x264: extra settings, `key=value:key=value`. */
57
+ readonly x264Params: string;
58
+ }
59
+ export declare const DEFAULT_SETTINGS: EncoderSettings;
60
+ export declare const X264_PRESETS: ReadonlyArray<string>;
61
+ export type EncoderName = "x264" | "vaapi-hevc" | "vaapi-h264" | "vaapi-hevc-cpuconv" | "vaapi-h264-cpuconv" | "nvenc-hevc" | "nvenc-h264" | "qsv-hevc" | "qsv-h264";
62
+ export declare const ENCODER_NAMES: ReadonlyArray<EncoderName>;
63
+ export declare const isEncoderName: (v: string) => v is EncoderName;
64
+ /** What "auto" tries, in order: a GPU's encoder when one works, else x264. */
65
+ export declare const AUTO_ENCODERS: ReadonlyArray<EncoderName>;
66
+ export declare const ENCODER_SUMMARIES: Readonly<Record<EncoderName, string>>;
67
+ /** The encoder `name` with `settings`. Throws EncoderError for settings that can't work. */
68
+ export declare function encoderSpec(name: EncoderName, settings: EncoderSettings, fps: number): EncoderSpec;