@tongue-core/sdk 0.1.0-rc.1 → 0.1.0-rc.3

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 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
@@ -51,7 +51,7 @@ await Effect.runPromise(program.pipe(Effect.provide(BunServices.layer)));
51
51
  | `describeFilm(film)` | the plan as text (for agents to read) and as a JSON summary |
52
52
  | `checkFilm(path)` | problems and warnings as data |
53
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 |
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
55
  | `listBackends()` | every raster and encoder, each tried for real |
56
56
  | `studioServer(path, { port, remote })` | the studio on a project (rebuilt and reloaded on every edit) or on a build served anywhere |
57
57
 
@@ -67,8 +67,11 @@ browser fetches it). `.tongue/types/film` links to the SDK's `@tongue-core/film`
67
67
  project's `tsconfig.json` points editors at.
68
68
 
69
69
  A **film module** (the way films were written before projects) is a `film.tsx` in a folder with a
70
- `package.json`, which installs `@tongue-core/film` and compiles JSX for it
71
- (`"jsx": "react-jsx", "jsxImportSource": "@tongue-core/film"`). It is imported and planned directly.
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.
72
75
 
73
76
  Either way, asset paths are relative to the root, Tongue writes `.tongue/` there (imported assets,
74
77
  builds, render work) and renders to `out/`.
@@ -23,7 +23,8 @@ export interface EncoderSpec {
23
23
  /** The filter from the raw input to what the encoder takes (uploads to the GPU). */
24
24
  readonly filter: string | null;
25
25
  readonly encode: ReadonlyArray<string>;
26
- /** AMD's HEVC encoder writes no conformance window, so 1080 rows decode as 1088. */
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. */
27
28
  readonly fixCrop: boolean;
28
29
  }
29
30
  /** Where ffmpeg and ffprobe are. */
@@ -23,10 +23,12 @@ async function run(cmd) {
23
23
  /** Muxes a raw HEVC stream into the MP4, setting the conformance window (no re-encode) when the
24
24
  * driver padded the picture. Done on the raw stream: an MP4 already carries the unpadded size. */
25
25
  async function muxHevc(tools, raw, target) {
26
- const probe = await run([tools.ffprobe, "-v", "error", "-select_streams", "v", "-show_frames", "-read_intervals", "%+#1", "-show_entries", "frame=width,height", "-of", "csv=p=0", raw]);
26
+ // From the coded size, not the decoded one: hevc_metadata replaces the stream's window, and the
27
+ // driver sometimes writes part of one (540 across is coded as 576 and windowed to 572).
28
+ const probe = await run([tools.ffprobe, "-v", "error", "-select_streams", "v", "-show_entries", "stream=coded_width,coded_height", "-of", "csv=p=0", raw]);
27
29
  const [width = 0, height = 0] = (probe.stdout.trim().split("\n")[0] ?? "").split(",").map(Number);
28
30
  if (width < target.width || height < target.height)
29
- throw new EncoderError(`${raw} decodes at ${width}×${height}, smaller than ${target.width}×${target.height}.`);
31
+ throw new EncoderError(`${raw} is coded at ${width}×${height}, smaller than ${target.width}×${target.height}.`);
30
32
  const crop = width !== target.width || height !== target.height ? ["-bsf:v", `hevc_metadata=crop_right=${width - target.width}:crop_bottom=${height - target.height}`] : [];
31
33
  const mux = await run([tools.ffmpeg, "-hide_banner", "-v", "error", "-y", "-framerate", String(target.fps), "-i", raw, "-c", "copy", ...crop, "-tag:v", "hvc1", target.out]);
32
34
  if (mux.code !== 0)
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export type { FilmSource } from "./render/protocol.js";
6
6
  export { importAssets, type ImportReport } from "./import/importer.js";
7
7
  export { checkFilm, type CheckReport, type CheckProblem } from "./check.js";
8
8
  export { renderStills, renderSheet, type StillsOptions, type Still } from "./render/stills.js";
9
- export { renderVideo, defaultWorkers, type VideoOptions, type VideoResult } from "./render/pipeline.js";
9
+ export { renderVideo, defaultWorkers, type RenderStart, type VideoOptions, type VideoResult } from "./render/pipeline.js";
10
10
  export { listBackends, type BackendsReport } from "./backends.js";
11
11
  export { studioServer, type StudioOptions } from "./studio/server.js";
12
12
  export { ENCODER_NAMES, AUTO_ENCODERS, DEFAULT_SETTINGS, isEncoderName, type EncoderName, type EncoderSettings } from "./encode/encoders.js";
package/dist/project.js CHANGED
@@ -37,7 +37,7 @@ export const openProject = Effect.fn("openProject")(function* (path) {
37
37
  /** A film module: imported, its assets imported, planned. */
38
38
  const openModule = Effect.fn("openModule")(function* (project) {
39
39
  const { value, declared } = yield* Effect.tryPromise({
40
- try: () => importFilm(project.film),
40
+ try: () => importFilm(project.film, project.workDir),
41
41
  catch: (e) => new FilmFailure({ film: project.film, phase: "load", message: `The film module failed to load: ${messageOf(e)}` }),
42
42
  });
43
43
  const assets = yield* Effect.try({
@@ -9,8 +9,9 @@ export interface LoadedFilm {
9
9
  readonly value: unknown;
10
10
  readonly declared: ReadonlyArray<Asset>;
11
11
  }
12
- /** Imports a film module (an absolute path), collecting the assets its modules declare. */
13
- export declare function importFilm(film: string): Promise<LoadedFilm>;
12
+ /** Imports a film module (an absolute path), collecting the assets its modules declare. `workDir`
13
+ * is where its bundle goes (the film's .tongue/). */
14
+ export declare function importFilm(film: string, workDir: string): Promise<LoadedFilm>;
14
15
  /** Media lengths (seconds) from the import manifest, by `kind:name`. */
15
16
  export declare function importedDurations(assetsDir: string): Promise<ReadonlyMap<string, number>>;
16
17
  /** Loads the bundles of a build on disk: each is a file of the build already. */
@@ -6,8 +6,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
- import { readFile } from "node:fs/promises";
10
- import { join } from "node:path";
9
+ import { existsSync } from "node:fs";
10
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
11
+ import { dirname, join, relative } from "node:path";
11
12
  import { pathToFileURL } from "node:url";
12
13
  import { collectAssets, planFilm } from "@tongue-core/film";
13
14
  import { openBuild, planFilmDoc } from "@tongue-core/project";
@@ -25,13 +26,43 @@ export function explainLoadError(message) {
25
26
  }
26
27
  // A module runs once per process, so a film's declarations are kept for when it is opened again.
27
28
  const loaded = new Map();
28
- /** Imports a film module (an absolute path), collecting the assets its modules declare. */
29
- export async function importFilm(film) {
29
+ /** An import of @tongue-core/film or one of its entries, in a bundle Bun wrote. */
30
+ const FILM_IMPORT = /(from\s*|import\s*\(\s*|import\s+)"(@tongue-core\/film(?:\/[\w.-]+)?)"/g;
31
+ /** A film module bundled to run against this process's @tongue-core/film. The module may have its
32
+ * own installed (its editor reads those types), but a film's elements and its scenes' now()
33
+ * belong to the copy that made them, and the engine reads this one; a project's bundles are linked
34
+ * to it the same way. JSX is compiled for Tongue whatever the folder's tsconfig says. The bundle is
35
+ * written to `workDir` under its hash, so render processes reuse it. */
36
+ async function bundleFilm(film, workDir) {
37
+ const result = await Bun.build({
38
+ entrypoints: [film], target: "bun", format: "esm", throw: false,
39
+ jsx: { runtime: "automatic", importSource: "@tongue-core/film" },
40
+ external: ["@tongue-core/film", "@tongue-core/film/*"],
41
+ });
42
+ const [output] = result.outputs;
43
+ if (!result.success || !output) {
44
+ throw new Error(result.logs.map((l) => (l.position ? `${relative(dirname(film), l.position.file)}:${l.position.line}:${l.position.column}: ${l.message}` : l.message)).join("\n") || "it doesn't bundle");
45
+ }
46
+ // Bun keeps an external's specifier as written; it becomes this copy's file.
47
+ const code = (await output.text()).replace(FILM_IMPORT, (_, lead, specifier) => `${lead}"${import.meta.resolve(specifier)}"`);
48
+ const file = join(workDir, "modules", `film.${new Bun.CryptoHasher("sha256").update(code).digest("hex").slice(0, 16)}.js`);
49
+ if (!existsSync(file)) {
50
+ await mkdir(dirname(file), { recursive: true });
51
+ const partial = `${file}.${process.pid}.tmp`;
52
+ await writeFile(partial, code);
53
+ await rename(partial, file);
54
+ }
55
+ return file;
56
+ }
57
+ /** Imports a film module (an absolute path), collecting the assets its modules declare. `workDir`
58
+ * is where its bundle goes (the film's .tongue/). */
59
+ export async function importFilm(film, workDir) {
30
60
  const known = loaded.get(film);
31
61
  if (known)
32
62
  return known;
33
63
  try {
34
- const { value: mod, assets } = await collectAssets(() => import(__rewriteRelativeImportExtension(film)));
64
+ const bundle = await bundleFilm(film, workDir);
65
+ const { value: mod, assets } = await collectAssets(() => import(__rewriteRelativeImportExtension(pathToFileURL(bundle).href)));
35
66
  const result = { value: typeof mod === "object" && mod !== null && "default" in mod ? mod.default : undefined, declared: assets };
36
67
  loaded.set(film, result);
37
68
  return result;
@@ -67,7 +98,8 @@ export const buildHost = (dir) => ({
67
98
  export async function loadPlan(source, assetsDir) {
68
99
  const media = mediaOf(await importedDurations(assetsDir));
69
100
  if (source.kind === "module") {
70
- const { value, declared } = await importFilm(source.film);
101
+ // The film's work folder holds its imported assets.
102
+ const { value, declared } = await importFilm(source.film, dirname(assetsDir));
71
103
  return planFilm(value, { assets: declared, media });
72
104
  }
73
105
  const opened = await Effect.runPromise(openBuild((path) => readFile(join(source.dir, path), "utf8"), buildHost(source.dir), source.manifest));
@@ -22,12 +22,22 @@ export interface VideoOptions {
22
22
  readonly max: number;
23
23
  };
24
24
  readonly audioBitrate?: string;
25
+ /** Once the request is checked and the raster and encoder are chosen, before any frame is drawn. */
26
+ readonly onStart?: (start: RenderStart) => void;
25
27
  readonly onProgress?: (progress: {
26
28
  readonly frames: number;
27
29
  readonly total: number;
28
30
  readonly fps: number;
29
31
  }) => void;
30
32
  }
33
+ export interface RenderStart {
34
+ readonly frames: number;
35
+ readonly width: number;
36
+ readonly height: number;
37
+ readonly raster: RasterName;
38
+ readonly encoder: string;
39
+ readonly workers: number;
40
+ }
31
41
  export interface VideoResult {
32
42
  readonly out: string;
33
43
  readonly width: number;
@@ -48,10 +48,13 @@ const runWorker = Effect.fn("runWorker")(function* (index, job, cwd, queue, onFr
48
48
  return yield* Queue.offer(mine, segment.value);
49
49
  });
50
50
  const ready = yield* Deferred.make();
51
+ const reported = { last: null };
51
52
  yield* handle.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.runForEach((line) => {
52
53
  const report = parseReport(line);
53
54
  if (!report)
54
55
  return Effect.void;
56
+ if (report.kind === "failed")
57
+ return Effect.sync(() => { reported.last = report; });
55
58
  if (report.kind === "ready")
56
59
  return Deferred.succeed(ready, undefined).pipe(Effect.andThen(next));
57
60
  const segment = segments[report.id];
@@ -61,6 +64,10 @@ const runWorker = Effect.fn("runWorker")(function* (index, job, cwd, queue, onFr
61
64
  }), Effect.mapError((e) => fail(e.message)));
62
65
  const code = yield* handle.exitCode.pipe(Effect.mapError((e) => fail(e.message)));
63
66
  if (code !== 0) {
67
+ // What the film did wrong, as stills says it; otherwise the worker's own last words.
68
+ const last = reported.last;
69
+ if (last)
70
+ return yield* new RenderFailure({ message: last.frame === null ? `A render worker failed: ${last.message}` : `Frame ${last.frame}: ${last.message}` });
64
71
  const errors = yield* Fiber.join(stderr).pipe(Effect.orElseSucceed(() => ""));
65
72
  return yield* fail(`exited with ${code}: ${errors.trim().split("\n").slice(-12).join("\n")}`);
66
73
  }
@@ -74,23 +81,31 @@ export const renderVideo = Effect.fn("renderVideo")(function* (film, options) {
74
81
  const to = options.to ?? plan.length;
75
82
  if (!(scale > 0 && scale <= 4))
76
83
  return yield* new InvalidRequest({ message: `scale must be above 0 and at most 4, not ${scale}.` });
77
- if (!(Number.isInteger(from) && Number.isInteger(to) && from >= 0 && to <= plan.length && to > from)) {
78
- return yield* new InvalidRequest({ message: `The range ${from}–${to} isn't inside the film (0–${plan.length}).` });
84
+ if (!(Number.isInteger(from) && Number.isInteger(to)))
85
+ return yield* new InvalidRequest({ message: `The range ${from}–${to} has to be whole frames.` });
86
+ if (to <= from)
87
+ return yield* new InvalidRequest({ message: `The range ${from}–${to} is empty: it has to end after it starts.` });
88
+ if (from < 0 || to > plan.length)
89
+ return yield* new InvalidRequest({ message: `The range ${from}–${to} isn't inside the film (frames 0–${plan.length}).` });
90
+ if (options.workers !== undefined && !(Number.isInteger(options.workers) && options.workers >= 1)) {
91
+ return yield* new InvalidRequest({ message: `workers must be a whole number, at least 1, not ${options.workers}.` });
79
92
  }
80
93
  const ffmpeg = yield* ffmpegPath;
81
94
  const ffprobe = yield* ffprobePath;
82
95
  const raster = yield* resolveRaster(options.raster ?? "auto");
83
96
  const settings = { ...DEFAULT_SETTINGS, ...options.settings };
84
- const encoder = yield* resolveEncoder(options.encoder ?? "auto", settings, plan.fps, sizeAtScale(plan, scale), ffmpeg);
97
+ const size = sizeAtScale(plan, scale);
98
+ const encoder = yield* resolveEncoder(options.encoder ?? "auto", settings, plan.fps, size, ffmpeg);
85
99
  // Each render works in its own folder (removed when it ends), so renders can run side by side.
86
100
  const renders = join(film.project.workDir, "render");
87
101
  yield* fs.makeDirectory(renders, { recursive: true });
88
102
  const work = yield* fs.makeTempDirectoryScoped({ directory: renders, prefix: "render-" });
89
103
  yield* fs.makeDirectory(dirname(options.out), { recursive: true });
90
104
  const wanted = Math.max(1, Math.min(options.workers ?? defaultWorkers(), raster.maxWorkers ?? Infinity));
91
- const size = options.segment ?? { min: Math.round(plan.fps), max: Math.round(plan.fps * 2) };
92
- const segments = planSegments(from, to, wanted, size).map((s, id) => ({ id, ...s, out: join(work, `seg-${String(id).padStart(4, "0")}.mp4`) }));
105
+ const segmentSize = options.segment ?? { min: Math.round(plan.fps), max: Math.round(plan.fps * 2) };
106
+ const segments = planSegments(from, to, wanted, segmentSize).map((s, id) => ({ id, ...s, out: join(work, `seg-${String(id).padStart(4, "0")}.mp4`) }));
93
107
  const workers = Math.min(wanted, segments.length);
108
+ options.onStart?.({ frames: to - from, width: size.width, height: size.height, raster: raster.name, encoder: encoder.spec.name, workers });
94
109
  const job = {
95
110
  source: film.source, root: film.project.root, assetsDir: film.project.assetsDir, scale,
96
111
  raster: raster.name, encoder: encoder.name, settings, ffmpeg, ffprobe,
@@ -126,6 +141,8 @@ export const renderVideo = Effect.fn("renderVideo")(function* (film, options) {
126
141
  const [width = 0, height = 0, frames = 0] = stdout.trim().split(",").map(Number);
127
142
  if (frames !== total)
128
143
  return yield* new RenderFailure({ message: `Expected ${total} frames in ${options.out}, found ${frames}.` });
144
+ if (width !== size.width || height !== size.height)
145
+ return yield* new RenderFailure({ message: `${options.out} came out ${width}×${height}, not ${size.width}×${size.height} (the ${encoder.spec.name} encoder).` });
129
146
  const seconds = (performance.now() - started) / 1000;
130
147
  const result = { out: options.out, width, height, frames, duration: total / plan.fps, raster: raster.name, encoder: encoder.spec.name, workers, seconds, fps: total / seconds };
131
148
  return result;
@@ -39,6 +39,12 @@ export type WorkerReport = {
39
39
  readonly kind: "done";
40
40
  readonly id: number;
41
41
  readonly ms: number;
42
+ }
43
+ /** The worker is about to exit: a frame failed to draw, or (no frame) loading or encoding failed. */
44
+ | {
45
+ readonly kind: "failed";
46
+ readonly frame: number | null;
47
+ readonly message: string;
42
48
  };
43
49
  export declare class ProtocolError extends Error {
44
50
  readonly name = "ProtocolError";
@@ -66,10 +66,23 @@ export function formatReport(r) {
66
66
  case "ready": return `ready ${r.ms.toFixed(0)}`;
67
67
  case "progress": return `progress ${r.id} ${r.frames}`;
68
68
  case "done": return `done ${r.id} ${r.ms.toFixed(0)}`;
69
+ // The message as a JSON string, so it stays on one line.
70
+ case "failed": return `failed ${r.frame ?? "-"} ${JSON.stringify(r.message)}`;
69
71
  }
70
72
  }
71
73
  /** A report line; anything else a worker prints (logs) is not a report. */
72
74
  export function parseReport(line) {
75
+ const failed = /^failed (-|\d+) (".*")$/.exec(line.trim());
76
+ if (failed) {
77
+ let message = null;
78
+ try {
79
+ message = JSON.parse(failed[2] ?? "");
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ return typeof message === "string" ? { kind: "failed", frame: failed[1] === "-" ? null : Number(failed[1]), message } : null;
85
+ }
73
86
  const [kind, ...rest] = line.trim().split(" ");
74
87
  const n = rest.map(Number);
75
88
  if (n.some((v) => !Number.isFinite(v)))
@@ -24,7 +24,7 @@ export const renderStills = Effect.fn("renderStills")(function* (film, options)
24
24
  return yield* new InvalidRequest({ message: `scale must be above 0 and at most 4, not ${scale}.` });
25
25
  const outside = options.frames.filter((f) => !(Number.isInteger(f) && f >= 0 && f < film.plan.length));
26
26
  if (outside.length)
27
- return yield* new InvalidRequest({ message: `Frames ${outside.join(", ")} are outside the film (0–${film.plan.length - 1}).` });
27
+ return yield* new InvalidRequest({ message: `${outside.length === 1 ? `Frame ${outside.join("")} is` : `Frames ${outside.join(", ")} are`} outside the film (0–${film.plan.length - 1}).` });
28
28
  const ffmpeg = yield* ffmpegPath;
29
29
  yield* fs.makeDirectory(options.outDir, { recursive: true });
30
30
  const fail = (e) => new RenderFailure({ message: messageOf(e) });
@@ -58,7 +58,7 @@ export const renderSheet = Effect.fn("renderSheet")(function* (film, options) {
58
58
  const c = i % columns, r = Math.floor(i / columns);
59
59
  return `${c === 0 ? "0" : Array.from({ length: c }, () => "w0").join("+")}_${r === 0 ? "0" : Array.from({ length: r }, () => "h0").join("+")}`;
60
60
  };
61
- const label = stills.map((s, i) => `[${i}]drawtext=text='${s.frame}':x=10:y=10:fontsize=24:fontcolor=white:box=1:boxcolor=black@0.6[l${i}]`).join(";");
61
+ const label = stills.map((s, i) => `[${i}]drawtext=text='${s.frame}':x=14:y=14:fontsize=24:fontcolor=white:box=1:boxcolor=black@0.6:boxborderw=6[l${i}]`).join(";");
62
62
  const stack = `${stills.map((_, i) => `[l${i}]`).join("")}xstack=inputs=${stills.length}:layout=${stills.map((_, i) => at(i)).join("|")}:fill=black`;
63
63
  const ffmpeg = yield* ffmpegPath;
64
64
  yield* runTool(ffmpeg, ["-y", "-v", "error", ...stills.flatMap((s) => ["-i", s.path]), "-filter_complex", `${label};${stack}`, out]);
@@ -14,42 +14,59 @@ import { FfmpegVideoFrames } from "./video-frames.js";
14
14
  // bun worker.ts '<job json>'
15
15
  const started = performance.now();
16
16
  const say = (r) => console.log(formatReport(r));
17
- const job = parseJob(process.argv[2] ?? "");
18
- const plan = await loadPlan(job.source, job.assetsDir);
19
- const loaded = await loadAssets(plan.assets, (path) => Bun.file(join(job.assetsDir, path)).bytes(), { audio: false });
20
- const backend = await rasterPlugin(job.raster).create();
21
- const renderer = await FilmRenderer.create(plan, loaded, backend);
22
- const video = new FfmpegVideoFrames(job.ffmpeg, (asset) => {
23
- const info = loaded.videos.get(asset.name)?.info;
24
- return info ? { path: join(job.root, asset.source), info } : null;
25
- });
26
- const spec = encoderSpec(job.encoder, job.settings, plan.fps);
27
- const { width, height } = renderer.sizeAt(job.scale);
28
- // Frames go to the encoder one in flight while the next is drawn, so three buffers cycle.
29
- const pool = [0, 1, 2].map(() => new Uint8Array(frameBytes(width, height, "yuv420p")));
30
- say({ kind: "ready", ms: performance.now() - started });
31
- for await (const line of console) {
32
- if (!line.trim())
33
- continue;
34
- const segment = parseSegment(line);
35
- const t0 = performance.now();
36
- const encoder = openEncoder({ ffmpeg: job.ffmpeg, ffprobe: job.ffprobe }, spec, { width, height, fps: plan.fps, out: segment.out });
37
- for (let f = segment.from; f < segment.to; f++) {
38
- const drawn = await frameWithVideo(renderer, f, job.scale, video);
39
- const frame = pool[f % pool.length] ?? new Uint8Array(0);
40
- if (rendersInto(backend))
41
- backend.renderInto(renderer.upload(drawn), "yuv420p", frame);
42
- else {
43
- renderer.draw(drawn);
44
- frame.set(renderer.yuv420());
17
+ const messageOf = (e) => (e instanceof Error ? e.message : String(e));
18
+ // A failure is reported before the worker exits, with the frame when one was being drawn, so the
19
+ // render can say what broke.
20
+ let drawing = null;
21
+ const failed = (e) => {
22
+ say({ kind: "failed", frame: drawing, message: messageOf(e) });
23
+ process.exit(1);
24
+ };
25
+ process.on("uncaughtException", failed);
26
+ process.on("unhandledRejection", failed);
27
+ try {
28
+ const job = parseJob(process.argv[2] ?? "");
29
+ const plan = await loadPlan(job.source, job.assetsDir);
30
+ const loaded = await loadAssets(plan.assets, (path) => Bun.file(join(job.assetsDir, path)).bytes(), { audio: false });
31
+ const backend = await rasterPlugin(job.raster).create();
32
+ const renderer = await FilmRenderer.create(plan, loaded, backend);
33
+ const video = new FfmpegVideoFrames(job.ffmpeg, (asset) => {
34
+ const info = loaded.videos.get(asset.name)?.info;
35
+ return info ? { path: join(job.root, asset.source), info } : null;
36
+ });
37
+ const spec = encoderSpec(job.encoder, job.settings, plan.fps);
38
+ const { width, height } = renderer.sizeAt(job.scale);
39
+ // Frames go to the encoder one in flight while the next is drawn, so three buffers cycle.
40
+ const pool = [0, 1, 2].map(() => new Uint8Array(frameBytes(width, height, "yuv420p")));
41
+ say({ kind: "ready", ms: performance.now() - started });
42
+ for await (const line of console) {
43
+ if (!line.trim())
44
+ continue;
45
+ const segment = parseSegment(line);
46
+ const t0 = performance.now();
47
+ const encoder = openEncoder({ ffmpeg: job.ffmpeg, ffprobe: job.ffprobe }, spec, { width, height, fps: plan.fps, out: segment.out });
48
+ for (let f = segment.from; f < segment.to; f++) {
49
+ drawing = f;
50
+ const drawn = await frameWithVideo(renderer, f, job.scale, video);
51
+ const frame = pool[f % pool.length] ?? new Uint8Array(0);
52
+ if (rendersInto(backend))
53
+ backend.renderInto(renderer.upload(drawn), "yuv420p", frame);
54
+ else {
55
+ renderer.draw(drawn);
56
+ frame.set(renderer.yuv420());
57
+ }
58
+ drawing = null;
59
+ await encoder.write(frame);
60
+ const done = f - segment.from + 1;
61
+ if (done % 4 === 0 && f < segment.to - 1)
62
+ say({ kind: "progress", id: segment.id, frames: done });
45
63
  }
46
- await encoder.write(frame);
47
- const done = f - segment.from + 1;
48
- if (done % 4 === 0 && f < segment.to - 1)
49
- say({ kind: "progress", id: segment.id, frames: done });
64
+ await encoder.finish();
65
+ say({ kind: "done", id: segment.id, ms: performance.now() - t0 });
50
66
  }
51
- await encoder.finish();
52
- say({ kind: "done", id: segment.id, ms: performance.now() - t0 });
67
+ video.close();
68
+ backend.dispose();
69
+ }
70
+ catch (e) {
71
+ failed(e);
53
72
  }
54
- video.close();
55
- backend.dispose();
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { basename, dirname, extname, join, normalize, relative, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { studioPage } from "@tongue-core/studio/page";
6
- import { Effect, FileSystem, Path } from "effect";
6
+ import { Cause, Effect, FileSystem, Path } from "effect";
7
7
  import { ChildProcessSpawner } from "effect/process";
8
8
  import { buildForWork } from "../build/build.js";
9
9
  import { FilmFailure, messageOf } from "../errors.js";
@@ -74,6 +74,8 @@ export const studioServer = Effect.fn("studioServer")(function* (filmPath, optio
74
74
  }
75
75
  }
76
76
  };
77
+ // What the last build said when it failed, for the studio and the terminal.
78
+ let failure = null;
77
79
  const rebuild = async () => {
78
80
  if (!project)
79
81
  return;
@@ -81,11 +83,12 @@ export const studioServer = Effect.fn("studioServer")(function* (filmPath, optio
81
83
  tell({ kind: "building" });
82
84
  const built = await run(buildForWork(project));
83
85
  if (built._tag === "Failure") {
84
- const message = `the project doesn't build (tongue check ${relative(process.cwd(), project.root) || "."} says why)`;
85
- log(`build failed: ${message}`);
86
- tell({ kind: "failed", message });
86
+ failure = messageOf(Cause.squash(built.cause));
87
+ log(`build failed: ${failure}`);
88
+ tell({ kind: "failed", message: failure });
87
89
  return;
88
90
  }
91
+ failure = null;
89
92
  current = built.value.dir;
90
93
  const ms = Math.round(performance.now() - started);
91
94
  log(`built in ${ms} ms`);
@@ -94,7 +97,7 @@ export const studioServer = Effect.fn("studioServer")(function* (filmPath, optio
94
97
  if (project) {
95
98
  yield* Effect.promise(rebuild);
96
99
  if (!current)
97
- return yield* new FilmFailure({ film: project.root, phase: "load", message: "The project doesn't build; run tongue check to see why." });
100
+ return yield* new FilmFailure({ film: project.root, phase: "load", message: `The project doesn't build: ${failure ?? `tongue check ${relative(process.cwd(), project.root) || "."} says why`}` });
98
101
  }
99
102
  const title = project ? basename(project.root) : options.remote ?? "remote build";
100
103
  const page = studioPage({
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@tongue-core/sdk",
3
- "version": "0.1.0-rc.1",
3
+ "version": "0.1.0-rc.3",
4
4
  "description": "Everything you do with a Tongue film, as a library: start a project from a template, build it into film.json, import, plan, check, render frames and video. Built with Effect.",
5
5
  "type": "module",
6
- "license": "UNLICENSED",
6
+ "license": "MIT",
7
7
  "author": "Backpine",
8
8
  "repository": {
9
9
  "type": "git",
@@ -35,16 +35,17 @@
35
35
  ],
36
36
  "dependencies": {
37
37
  "@effect/platform-bun": "4.0.6",
38
- "@tongue-core/engine": "0.1.0-rc.1",
39
- "@tongue-core/film": "0.1.0-rc.1",
40
- "@tongue-core/project": "0.1.0-rc.1",
41
- "@tongue-core/studio": "0.1.0-rc.1",
42
- "@tongue-core/templates": "0.1.0-rc.1",
43
- "@tongue-core/ui": "0.1.0-rc.1",
38
+ "@effect/platform-node-shared": "4.0.6",
39
+ "@tongue-core/engine": "0.1.0-rc.3",
40
+ "@tongue-core/film": "0.1.0-rc.3",
41
+ "@tongue-core/project": "0.1.0-rc.3",
42
+ "@tongue-core/studio": "0.1.0-rc.3",
43
+ "@tongue-core/templates": "0.1.0-rc.3",
44
+ "@tongue-core/ui": "0.1.0-rc.3",
44
45
  "effect": "4.0.6"
45
46
  },
46
47
  "optionalDependencies": {
47
- "@tongue-core/raster-linux-x64": "0.1.0-rc.1"
48
+ "@tongue-core/raster-linux-x64": "0.1.0-rc.3"
48
49
  },
49
50
  "publishConfig": {
50
51
  "access": "public"