@tongue-core/sdk 0.1.0-rc.8 → 0.1.0-rc.9

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/README.md CHANGED
@@ -51,8 +51,8 @@ processes, from Bun): provide it once, where your application runs its Effects.
51
51
  |---|---|
52
52
  | `initProject(dir, { template, name })` | a film project in a new or empty folder, from a built-in template or a template folder |
53
53
  | `listTemplates()` / `readTemplate(nameOrPath)` | the built-in templates, or one by name or path |
54
- | `buildFilm(project, { out })` | builds a film project into `out` (default `dist/`): build.json, film.json, a bundle per scene |
55
- | `openFilm(path, { scene, sources })` | a film ready to render: a project is built (into `.tongue/builds/<id>`, named by its content) and planned from film.json, a build folder (what `buildFilm` writes) is opened as it is, 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; `sources` says where videos are decoded from ([Footage](#footage)) |
54
+ | `buildFilm(project, { out, media })` | builds a film project into `out` (default `dist/`): build.json, film.json, a bundle per scene; `media` configures acquisition and hosted previews |
55
+ | `openFilm(path, { scene, sources, media })` | a film ready to render: a project is built (into `.tongue/builds/<id>`, named by its content) and planned from film.json, a build folder (what `buildFilm` writes) is opened as it is, a film module is imported and planned; assets imported either way. `scene: { id, props }` opens one scene of a project on its own; `sources` overrides video originals; `media` configures hosted-media acquisition and caching ([Footage](#footage)) |
56
56
  | `describeFilm(film)` | the plan as text (for agents to read) and as a JSON summary |
57
57
  | `checkFilm(path)` | problems and warnings as data |
58
58
  | `renderStills(film, { frames, scale, outDir })` / `renderSheet(…)` | PNGs drawn by the wasm raster (the player's), or tiled into one labelled sheet |
@@ -81,8 +81,9 @@ it. The folder may install `@tongue-core/film` too, for its editor's types
81
81
  (`"jsx": "react-jsx", "jsxImportSource": "@tongue-core/film"` in its tsconfig); that copy isn't the one
82
82
  that runs.
83
83
 
84
- Either way, asset paths are relative to the root, Tongue writes `.tongue/` there (imported assets,
85
- builds, render work) and renders to `out/`.
84
+ Local asset paths are relative to the root. Tongue writes `.tongue/` there for builds and render
85
+ work, and renders to `out/`. Local converted assets use the project's cache; hosted-media
86
+ downloads and preparation use the shared cache outside the project.
86
87
 
87
88
  A film's assets are what its modules declare (`font()`, `image()`, `audio()`, `video()`) while
88
89
  the SDK loads it, plus the media its clips play; `<Film assets={[…]}>` replaces the declared ones.
@@ -91,6 +92,59 @@ and a poster frame.
91
92
 
92
93
  ## Footage
93
94
 
95
+ Hosted URLs are the normal source for application-owned media. Declare them with `video`, `audio`,
96
+ `image` or `font`, with an explicit name and a SHA-256 when the application knows it. The SDK
97
+ streams downloads into a shared cache outside the project, verifies content, and atomically
98
+ promotes completed files. Concurrent requests for the same pinned content share a download.
99
+ Unpinned URLs are revalidated with ETag/Last-Modified when available. Temporary network failures,
100
+ 408, 429 and server errors retry twice by default, with bounded backoff and Retry-After support;
101
+ missing files, denied access and integrity failures are reported as `MediaFailure`.
102
+
103
+ ```ts
104
+ const tongue = createTongue({
105
+ media: {
106
+ cacheDir: "/var/cache/tongue/media",
107
+ store: { directory: "/srv/media", baseUrl: "https://media.example/prepared/" },
108
+ },
109
+ });
110
+ await tongue.build("my-film");
111
+ await tongue.render("my-film/dist", { out: "out/film.mp4" });
112
+ ```
113
+
114
+ A portable build contains manifests and scene bundles, with media locations and hashes in
115
+ `build.json` version 2. Originals and generated remote media are never copied into it. Local
116
+ assets can still be packaged, and version 1 builds remain readable. The host must expose the
117
+ configured store at its `baseUrl` (with CORS for browser clients). The CLI reads the equivalent
118
+ `TONGUE_MEDIA_DIR`, `TONGUE_MEDIA_URL` and `TONGUE_MEDIA_CACHE` environment variables. The default
119
+ cache is `$XDG_CACHE_HOME/tongue/media` or `~/.cache/tongue/media`. `tongue studio` supplies a
120
+ local media store and serves it automatically.
121
+
122
+ For object storage, provide `media.publish(upload)`, an Effect returning the uploaded file's
123
+ HTTP(S) URL. `upload` holds the local `file`, `hash`, `bytes` and `contentType`. Storage belongs
124
+ to the application. A publish callback must finish storing the bytes before returning its URL.
125
+ Use the hash as an idempotent storage key and return a stable URL, so projects share uploaded
126
+ bytes. The built-in directory store does this automatically. Invalid publisher locations fail
127
+ before a portable build is written.
128
+
129
+ Applications can prepare media at ingestion: `prepareMedia(asset, mediaOptions)` returns a
130
+ `tongue.media` manifest with a canonical asset name, import recipe, source hash, duration and
131
+ prepared files. Host that JSON and pass its URL as the declaration's `prepared` option. The same
132
+ preparation is shared across projects and asset names. Preparation recipes include sizing and
133
+ format versions, so incompatible results are rebuilt. Audio previews are sample-indexed WAV
134
+ segments; video previews use the existing segmented H.264 format. Full-quality decoded audio
135
+ remains available to final renders.
136
+
137
+ `media.request(url, signal, headers)` can add authentication or refresh a signed URL without
138
+ serializing credentials into the build. Preserve the supplied signal and conditional headers.
139
+ `retries`, `retryDelayMs`, `timeoutMs`, `concurrency` and `onProgress` control acquisition. Original
140
+ downloads default to a 30-minute timeout; metadata/preview requests default to two minutes.
141
+ Cache contents are shared and host-managed; changing the cache directory isolates a job.
142
+
143
+ Final rendering acquires and verifies its video originals before starting any frame worker.
144
+ Converted assets are also acquired before use. Workers decode local files, with no networking
145
+ in the per-frame path. A fully cached pinned build can render with networking disabled. Build
146
+ snapshots retain immutable prepared-file bindings when a later version changes its assets.
147
+
94
148
  A render decodes every video from its **original file**: a build holds only a video's facts, a
95
149
  poster and a preview-quality proxy (what the studio plays). Before a frame is drawn, `renderVideo`
96
150
  and `renderStills` check that each video's file is there and fail with `AssetFailure` naming the
@@ -98,7 +152,7 @@ ones that aren't; a frame ffmpeg can't decode, or a file shorter than its import
98
152
  render with `Frame <n>: Video "<name>": …` and ffmpeg's reason. A poster never stands in for
99
153
  footage in a render (that is the player's fallback while frames arrive).
100
154
 
101
- A video is decoded from its source path under the film's root, unless `sources` says otherwise:
155
+ A local video is decoded from its source path under the film's root, unless `sources` says otherwise:
102
156
 
103
157
  ```ts
104
158
  const film = yield* openFilm("renders/1234/dist", { // a build, without the project around it
@@ -106,8 +160,9 @@ const film = yield* openFilm("renders/1234/dist", { // a build, without the pr
106
160
  });
107
161
  ```
108
162
 
109
- A build inside a project (`my-film/dist`) finds its footage in the project; a build on its own
110
- needs `sources` for every video that isn't beside it.
163
+ A build inside a project (`my-film/dist`) finds local footage in the project; a build on its own
164
+ needs `sources` for local videos that aren't beside it. Remote originals are resolved automatically
165
+ from the pinned registry in the manifest.
111
166
 
112
167
  ## Sound processing (extensions)
113
168
 
@@ -3,6 +3,7 @@ import { type Build, type ModuleHost, type ProjectFiles } from "@tongue-core/pro
3
3
  import { Effect, FileSystem, type PlatformError } from "effect";
4
4
  import { type ImportReport } from "../import/importer.js";
5
5
  import type { Project } from "../project.js";
6
+ import { type MediaOptions } from "../media/cache.js";
6
7
  /** A project's code and data: every .ts, .tsx and .json file outside hidden folders and the ones
7
8
  * tools write. */
8
9
  export declare const readProjectFiles: (root: string) => Effect.Effect<ProjectFiles, PlatformError.PlatformError, FileSystem.FileSystem>;
@@ -11,6 +12,7 @@ export declare const fileModuleHost: (dir: string) => ModuleHost;
11
12
  /** Points the project's `.tongue/types/film` at this SDK's @tongue-core/film (tsconfig.json reads it). */
12
13
  export declare const linkFilmTypes: (root: string) => Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem>;
13
14
  export interface BuiltProject {
15
+ readonly assetsDir: string;
14
16
  readonly build: Build;
15
17
  /** Where the build was written. */
16
18
  readonly dir: string;
@@ -25,6 +27,7 @@ export declare const importedFiles: (project: Project, assets: readonly Asset[])
25
27
  readonly path: string;
26
28
  readonly bytes: number;
27
29
  readonly hash: string;
30
+ readonly url?: string | undefined;
28
31
  }[], PlatformError.PlatformError, FileSystem.FileSystem>;
29
32
  /** How a written build reaches its assets: copies of the files (a build to publish), or a link to
30
33
  * the project's imported assets (a build for Tongue's own use). */
@@ -43,6 +46,6 @@ export declare const writeBuildFiles: (dir: string, build: Build, assets: AssetP
43
46
  export declare const keepBuild: (project: Project, build: Build) => Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem>;
44
47
  /** Builds a film project into `dir` (what `tongue build` does: dist/ by default), replacing what
45
48
  * 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>;
49
+ export declare const buildFilmProject: (project: Project, dir: string, options?: MediaOptions | undefined) => Effect.Effect<BuiltProject, import("../errors.js").AssetFailure | import("../errors.js").ToolFailure | import("@tongue-core/project/media").MediaFailure | 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/Schema").SchemaError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | import("effect/Path").Path>;
47
50
  /** 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>;
51
+ export declare const buildForWork: (project: Project, options?: MediaOptions | undefined) => Effect.Effect<BuiltProject, import("../errors.js").AssetFailure | import("../errors.js").ToolFailure | import("@tongue-core/project/media").MediaFailure | 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/Schema").SchemaError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | import("effect/Path").Path>;
@@ -14,6 +14,9 @@ import { compileProject, extractFilm, IGNORED_DIRS, loadProject, sha256, TYPES_L
14
14
  import { Effect, FileSystem } from "effect";
15
15
  import { importAssets } from "../import/importer.js";
16
16
  import { sdkVersion } from "../version.js";
17
+ import { isRemoteSource } from "@tongue-core/project/media";
18
+ import { mediaCacheDir, mediaLock } from "../media/cache.js";
19
+ import { snapshotAssets } from "../media/snapshot.js";
17
20
  // Building a film project on this machine: read its code, compile and bundle it, run the bundles,
18
21
  // import the assets they declare (media lengths go into film.json), extract film.json and write
19
22
  // the build, with the imported asset files listed in build.json (and copied beside it, or linked,
@@ -99,6 +102,8 @@ export const writeBuildFiles = Effect.fn("writeBuildFiles")(function* (dir, buil
99
102
  return;
100
103
  }
101
104
  for (const file of build.manifest.assets.files) {
105
+ if (file.url !== undefined)
106
+ continue;
102
107
  const path = join(assetsDir, file.path);
103
108
  yield* fs.makeDirectory(dirname(path), { recursive: true });
104
109
  yield* fs.copyFile(join(assets.from, file.path), path);
@@ -135,28 +140,36 @@ const pruneBuilds = Effect.fn("pruneBuilds")(function* (builds, keep) {
135
140
  });
136
141
  /** A film project built in memory: compiled, its bundles run, its assets imported (their lengths
137
142
  * 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 };
143
+ const buildInMemory = Effect.fn("buildInMemory")(function* (input, options = {}) {
144
+ return yield* mediaLock(`project-build:${input.root}`, Effect.gen(function* () {
145
+ let project = input;
146
+ const files = yield* readProjectFiles(project.root);
147
+ const compiled = yield* compileProject(files);
148
+ const loaded = yield* loadProject(compiled, fileModuleHost(join(project.workDir, "modules")));
149
+ if (loaded.assets.some((asset) => isRemoteSource(asset.source)))
150
+ project = { ...project, assetsDir: join(mediaCacheDir(options), "projects", createHash("sha256").update(project.root).digest("hex"), "assets") };
151
+ const imported = yield* importAssets(project, loaded.assets, options);
152
+ const media = { duration: (asset) => imported.durations.get(`${asset.kind}:${asset.name}`) ?? null };
153
+ const doc = yield* extractFilm(loaded, { media });
154
+ const local = yield* importedFiles(project, loaded.assets.filter((asset) => !isRemoteSource(asset.source)));
155
+ const build = writeBuild(compiled, doc, { tongue: yield* sdkVersion, assets: [...local, ...(imported.remoteFiles ?? [])], originals: imported.originals });
156
+ if ((imported.originals?.length ?? 0) > 0 && !options.portable)
157
+ project = { ...project, assetsDir: yield* snapshotAssets(project.assetsDir, build.manifest.assets.files, imported.remoteFiles ?? [], options) };
158
+ yield* linkFilmTypes(project.root);
159
+ return { build, imported, scenes: new Map(loaded.scenes.map((s) => [s.id, s.scene])), media, assetsDir: project.assetsDir };
160
+ }));
148
161
  });
149
162
  /** Builds a film project into `dir` (what `tongue build` does: dist/ by default), replacing what
150
163
  * 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 });
164
+ export const buildFilmProject = Effect.fn("buildFilmProject")(function* (project, dir, options = {}) {
165
+ const built = yield* buildInMemory(project, { ...options, portable: true });
166
+ yield* writeBuildFiles(dir, built.build, { kind: "copy", from: built.assetsDir });
154
167
  const out = { ...built, dir };
155
168
  return out;
156
169
  });
157
170
  /** 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) };
171
+ export const buildForWork = Effect.fn("buildForWork")(function* (project, options = {}) {
172
+ const built = yield* buildInMemory(project, options);
173
+ const out = { ...built, dir: yield* keepBuild({ ...project, assetsDir: built.assetsDir }, built.build) };
161
174
  return out;
162
175
  });
package/dist/check.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { PlanSummary } from "@tongue-core/film";
2
2
  import { Effect } from "effect";
3
+ import { type OpenOptions } from "./project.js";
3
4
  export interface CheckProblem {
4
5
  /** layout, compile, load, timeline, doc (film.json), asset. */
5
6
  readonly kind: string;
@@ -15,4 +16,4 @@ export interface CheckReport {
15
16
  readonly summary: PlanSummary | null;
16
17
  readonly text: string | null;
17
18
  }
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>;
19
+ export declare const checkFilm: (path: string, options?: OpenOptions | undefined) => Effect.Effect<CheckReport, import("./errors.js").InvalidRequest | import("./errors.js").ToolFailure | import("effect/Config").ConfigError | import("effect/PlatformError").PlatformError | import("effect/Schema").SchemaError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | import("effect/FileSystem").FileSystem | import("effect/Path").Path>;
package/dist/check.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { Effect } from "effect";
2
2
  import { describeFilm, openFilm } from "./project.js";
3
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) => {
4
+ export const checkFilm = Effect.fn("checkFilm")(function* (path, options = {}) {
5
+ return yield* openFilm(path, options).pipe(Effect.map((film) => {
6
6
  const { text, summary } = describeFilm(film);
7
7
  return { ok: true, problems: [], warnings: film.plan.warnings, summary, text };
8
8
  }), Effect.catchTags({
9
9
  FilmFailure: (e) => Effect.succeed(failed([{ kind: e.phase === "load" ? "load" : "timeline", file: e.film, message: e.message }])),
10
10
  AssetFailure: (e) => Effect.succeed(failed([{ kind: "asset", file: null, message: e.message }])),
11
+ MediaFailure: (e) => Effect.succeed(failed([{ kind: "asset", file: null, message: e.message }])),
11
12
  LayoutError: (e) => Effect.succeed(failed(e.problems.map((p) => ({ kind: "layout", file: p.file, message: p.message })))),
12
13
  CompileError: (e) => Effect.succeed(failed([{ kind: "compile", file: e.line === null ? e.file : `${e.file}:${e.line}`, message: e.message }])),
13
14
  LoadError: (e) => Effect.succeed(failed([{ kind: "load", file: e.file, message: e.message }])),
package/dist/client.d.ts CHANGED
@@ -2,7 +2,10 @@ import { type InitOptions } from "./init.js";
2
2
  import { type OpenOptions } from "./project.js";
3
3
  import { type VideoOptions } from "./render/pipeline.js";
4
4
  import { type StillsOptions } from "./render/stills.js";
5
- export declare function createTongue(): {
5
+ import type { MediaOptions } from "./media/cache.js";
6
+ export declare function createTongue(configuration?: {
7
+ readonly media?: MediaOptions;
8
+ }): {
6
9
  /** Makes a film project in a new or empty folder from a template. */
7
10
  init: (dir: string, options?: InitOptions) => Promise<import("./init.js").InitReport>;
8
11
  /** The templates that come with Tongue. */
@@ -18,6 +21,7 @@ export declare function createTongue(): {
18
21
  /** Builds a film project: build.json, film.json and a bundle per scene, in its dist folder. */
19
22
  build: (project: string, options?: {
20
23
  readonly out?: string;
24
+ readonly media?: MediaOptions;
21
25
  }) => Promise<import("./index.js").BuiltProject>;
22
26
  /** Imports the film's assets and plans it; resolves to its timeline as text and JSON. */
23
27
  info: (film: string) => Promise<{
package/dist/client.js CHANGED
@@ -14,24 +14,24 @@ const run = async (effect) => {
14
14
  return exit.value;
15
15
  throw Cause.squash(exit.cause);
16
16
  };
17
- export function createTongue() {
17
+ export function createTongue(configuration = {}) {
18
18
  return {
19
19
  /** Makes a film project in a new or empty folder from a template. */
20
20
  init: (dir, options) => run(initProject(dir, options)),
21
21
  /** The templates that come with Tongue. */
22
22
  templates: () => run(listTemplates()),
23
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)),
24
+ build: (project, options) => run(buildFilm(project, { media: configuration.media, ...options })),
25
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))),
26
+ info: (film) => run(openFilm(film, configuration).pipe(Effect.map(describeFilm))),
27
27
  /** Problems and warnings as data: never rejects for a broken film. */
28
- check: (film) => run(checkFilm(film)),
28
+ check: (film) => run(checkFilm(film, configuration)),
29
29
  /** Frames as PNG files. */
30
- stills: (film, options) => run(openFilm(film, options).pipe(Effect.flatMap((f) => renderStills(f, options)))),
30
+ stills: (film, options) => run(openFilm(film, { ...configuration, ...options }).pipe(Effect.flatMap((f) => renderStills(f, options)))),
31
31
  /** Frames tiled into one labelled image. */
32
- sheet: (film, options) => run(openFilm(film, options).pipe(Effect.flatMap((f) => renderSheet(f, options)))),
32
+ sheet: (film, options) => run(openFilm(film, { ...configuration, ...options }).pipe(Effect.flatMap((f) => renderSheet(f, options)))),
33
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)))),
34
+ render: (film, options) => run(openFilm(film, { ...configuration, ...options }).pipe(Effect.flatMap((f) => renderVideo(f, options)))),
35
35
  /** The rasters and encoders this machine can use. */
36
36
  backends: () => run(listBackends()),
37
37
  };
package/dist/errors.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { CompileError, DocError, LayoutError, LoadError, TemplateError, TimelineError } from "@tongue-core/project";
2
2
  import { Schema } from "effect";
3
+ import { MediaFailure } from "@tongue-core/project/media";
3
4
  declare const FilmFailure_base: Schema.Class<FilmFailure, Schema.TaggedStruct<"FilmFailure", {
4
5
  readonly film: Schema.String;
5
6
  /** "load" (the module threw or isn't there), "film" (not a <Film>, or the timeline is wrong). */
@@ -54,9 +55,9 @@ declare const ProcessorFailure_base: Schema.Class<ProcessorFailure, Schema.Tagge
54
55
  * channels, another length) or isn't audio (a sample that isn't a finite number). */
55
56
  export declare class ProcessorFailure extends ProcessorFailure_base {
56
57
  }
57
- export declare const SdkError: Schema.Union<readonly [typeof FilmFailure, typeof AssetFailure, typeof InvalidRequest, typeof ToolFailure, typeof BackendUnavailable, typeof RenderFailure, typeof ProcessorFailure, typeof LayoutError, typeof CompileError, typeof LoadError, typeof TimelineError, typeof DocError, typeof TemplateError]>;
58
+ export declare const SdkError: Schema.Union<readonly [typeof FilmFailure, typeof AssetFailure, typeof InvalidRequest, typeof ToolFailure, typeof BackendUnavailable, typeof RenderFailure, typeof ProcessorFailure, typeof MediaFailure, typeof LayoutError, typeof CompileError, typeof LoadError, typeof TimelineError, typeof DocError, typeof TemplateError]>;
58
59
  export type SdkError = typeof SdkError.Type;
59
- export declare const isSdkError: <I>(input: I) => input is I & (FilmFailure | AssetFailure | InvalidRequest | ToolFailure | BackendUnavailable | RenderFailure | ProcessorFailure | LayoutError | CompileError | LoadError | TimelineError | DocError | TemplateError);
60
+ export declare const isSdkError: <I>(input: I) => input is I & (FilmFailure | AssetFailure | InvalidRequest | ToolFailure | BackendUnavailable | RenderFailure | ProcessorFailure | MediaFailure | LayoutError | CompileError | LoadError | TimelineError | DocError | TemplateError);
60
61
  /** Exhaustive: a new error without a case is a compile error. */
61
62
  export declare function exitCodeOf(error: SdkError): number;
62
63
  /** The message of anything thrown. */
package/dist/errors.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { CompileError, DocError, LayoutError, LoadError, TemplateError, TimelineError } from "@tongue-core/project";
2
2
  import { Schema } from "effect";
3
+ import { MediaFailure } from "@tongue-core/project/media";
3
4
  // Every way the SDK can fail, as tagged errors with a message an agent (or a person) can act on.
4
5
  // `exitCodeOf` maps each to the CLI's exit code: 2 when the input is wrong (fix the film or the
5
6
  // arguments), 3 when the machine is (a missing tool, a device, a crash). Building a film project
@@ -52,7 +53,7 @@ export class ProcessorFailure extends Schema.TaggedError()("ProcessorFailure", {
52
53
  }) {
53
54
  }
54
55
  export const SdkError = Schema.Union([
55
- FilmFailure, AssetFailure, InvalidRequest, ToolFailure, BackendUnavailable, RenderFailure, ProcessorFailure,
56
+ FilmFailure, AssetFailure, InvalidRequest, ToolFailure, BackendUnavailable, RenderFailure, ProcessorFailure, MediaFailure,
56
57
  LayoutError, CompileError, LoadError, TimelineError, DocError, TemplateError,
57
58
  ]);
58
59
  export const isSdkError = Schema.is(SdkError);
@@ -60,6 +61,7 @@ export const isSdkError = Schema.is(SdkError);
60
61
  export function exitCodeOf(error) {
61
62
  switch (error._tag) {
62
63
  case "FilmFailure":
64
+ case "MediaFailure":
63
65
  case "AssetFailure":
64
66
  case "InvalidRequest":
65
67
  case "LayoutError":
@@ -11,9 +11,11 @@ export function fontToTtf(bytes) {
11
11
  }
12
12
  /** Decodes the image formats the engine reads itself; null for the rest (ffmpeg decodes those). */
13
13
  export function decodeImage(bytes, extension) {
14
- if (extension === ".png")
14
+ const png = bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71;
15
+ const jpeg = bytes[0] === 255 && bytes[1] === 216;
16
+ if (png || extension === ".png")
15
17
  return decodePng(bytes, (z) => new Uint8Array(inflateSync(z)));
16
- if (extension === ".jpg" || extension === ".jpeg")
18
+ if (jpeg || extension === ".jpg" || extension === ".jpeg")
17
19
  return decodeJpeg(bytes);
18
20
  return null;
19
21
  }
@@ -1,11 +1,26 @@
1
1
  import type { Asset } from "@tongue-core/film";
2
2
  import { Effect, FileSystem, Path } from "effect";
3
- import { AssetFailure } from "../errors.js";
4
3
  import type { Project } from "../project.js";
5
- export interface ImportReport {
6
- readonly imported: ReadonlyArray<string>;
7
- readonly skipped: ReadonlyArray<string>;
8
- /** Media lengths in seconds, by `kind:name`. */
9
- readonly durations: ReadonlyMap<string, number>;
10
- }
11
- export declare const importAssets: (project: Project, assets: readonly Asset[]) => Effect.Effect<ImportReport, AssetFailure | import("../errors.js").ToolFailure | import("effect/Config").ConfigError | import("effect/PlatformError").PlatformError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | Path.Path>;
4
+ import { type MediaOptions } from "../media/cache.js";
5
+ export type { ImportReport } from "./types.js";
6
+ /** Both source kinds converge on the engine's virtual asset paths. */
7
+ export declare const importAssets: (project: Project, assets: readonly Asset[], configuration?: (MediaOptions & {
8
+ readonly portable?: boolean;
9
+ }) | undefined) => Effect.Effect<{
10
+ imported: string[];
11
+ skipped: string[];
12
+ durations: Map<string, number>;
13
+ remoteFiles: {
14
+ readonly path: string;
15
+ readonly bytes: number;
16
+ readonly hash: string;
17
+ readonly url?: string | undefined;
18
+ }[];
19
+ originals: {
20
+ readonly kind: "font" | "image" | "audio" | "video";
21
+ readonly name: string;
22
+ readonly source: string;
23
+ readonly hash: string;
24
+ readonly bytes: number;
25
+ }[];
26
+ }, import("../errors.js").AssetFailure | import("../errors.js").ToolFailure | import("@tongue-core/project/media").MediaFailure | import("effect/Config").ConfigError | import("effect/PlatformError").PlatformError | import("effect/Schema").SchemaError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | Path.Path>;
@@ -1,96 +1,30 @@
1
- import { importedPath, videoPosterPath, videoProxyDir } from "@tongue-core/engine";
2
- import { Effect, FileSystem, Path, Schema } from "effect";
3
- import { AssetFailure, messageOf } from "../errors.js";
4
- import { decodeImage, fontToTtf, imageToQoi } from "./convert.js";
5
- import { audioToWav, imageViaFfmpeg, importVideo, makeProxy, probeMedia, proxyInfo } from "./media.js";
6
- // Importing: every asset a film declares, turned into what the engine reads (TrueType fonts, QOI
7
- // images, WAV audio, video facts, posters and proxies) under .tongue/assets. A manifest remembers each
8
- // source's size, modified time and options, so unchanged assets are skipped.
9
- const Entry = Schema.Struct({
10
- fingerprint: Schema.String,
11
- /** Seconds, for audio and video. */
12
- duration: Schema.optional(Schema.Number),
13
- });
14
- const Manifest = Schema.Struct({ version: Schema.Literal(1), assets: Schema.Record(Schema.String, Entry) });
15
- const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest));
16
- const keyOf = (asset) => `${asset.kind}:${asset.name}`;
17
- /** What else an import depends on: a different size, or proxies made a new way, imports again. */
18
- const PROXY_VERSION = 2;
19
- const optionsOf = (asset) => (asset.kind === "image" ? `max${asset.maxSize}` : asset.kind === "video" ? `max${asset.maxSize};proxy${PROXY_VERSION}` : "");
20
- export const importAssets = Effect.fn("importAssets")(function* (project, assets) {
21
- const fs = yield* FileSystem.FileSystem;
22
- const path = yield* Path.Path;
23
- const manifestPath = path.join(project.assetsDir, "manifest.json");
24
- const empty = { version: 1, assets: {} };
25
- const previous = yield* fs.readFileString(manifestPath).pipe(Effect.flatMap(decodeManifest), Effect.orElseSucceed(() => empty));
26
- const write = Effect.fn("write")(function* (relative, bytes) {
27
- const target = path.join(project.assetsDir, relative);
28
- yield* fs.makeDirectory(path.dirname(target), { recursive: true });
29
- yield* fs.writeFile(target, bytes);
30
- });
31
- const results = yield* Effect.forEach(assets, (asset) => Effect.gen(function* () {
32
- const key = keyOf(asset);
33
- const source = path.resolve(project.root, asset.source);
34
- const stat = yield* fs.stat(source).pipe(Effect.mapError(() => new AssetFailure({ asset: key, message: `${asset.kind} "${asset.name}": no file at ${asset.source} (relative to ${project.root}).` })));
35
- const modified = stat.mtime._tag === "Some" ? stat.mtime.value.getTime() : 0;
36
- const fingerprint = `${Number(stat.size)}:${modified}:${optionsOf(asset)}`;
37
- const known = previous.assets[key];
38
- const output = path.join(project.assetsDir, importedPath(asset));
39
- if (known?.fingerprint === fingerprint && (yield* fs.exists(output).pipe(Effect.orElseSucceed(() => false)))) {
40
- return { key, entry: known, imported: false };
41
- }
42
- const read = fs.readFile(source).pipe(Effect.mapError((e) => new AssetFailure({ asset: key, message: `Could not read ${source}: ${e.message}` })));
43
- const convert = (run) => Effect.try({ try: run, catch: (e) => new AssetFailure({ asset: key, message: `${asset.kind} "${asset.name}" (${asset.source}): ${messageOf(e)}` }) });
44
- let duration;
45
- switch (asset.kind) {
46
- case "font": {
47
- const bytes = yield* read;
48
- yield* write(importedPath(asset), yield* convert(() => fontToTtf(bytes)));
49
- break;
50
- }
51
- case "image": {
52
- const bytes = yield* read;
53
- const decoded = yield* Effect.try({ try: () => decodeImage(bytes, path.extname(source).toLowerCase()), catch: (e) => new AssetFailure({ asset: key, message: `${asset.source}: ${messageOf(e)}` }) });
54
- const image = decoded ?? (yield* imageViaFfmpeg(key, source));
55
- yield* write(importedPath(asset), yield* convert(() => imageToQoi(image, asset.maxSize)));
56
- break;
57
- }
58
- case "audio": {
59
- duration = (yield* probeMedia(key, source)).duration;
60
- yield* write(importedPath(asset), yield* audioToWav(source));
61
- break;
62
- }
63
- case "video": {
64
- const video = yield* importVideo(key, source, asset.maxSize);
65
- duration = video.duration;
66
- // The proxy, made fresh into its folder; its segments' facts go into info.json.
67
- const proxyDir = path.join(project.assetsDir, videoProxyDir(asset));
68
- yield* fs.remove(proxyDir, { recursive: true, force: true });
69
- yield* fs.makeDirectory(proxyDir, { recursive: true });
70
- yield* makeProxy(source, video.info, proxyDir).pipe(Effect.mapError((e) => new AssetFailure({ asset: key, message: `video "${asset.name}": its proxy couldn't be made: ${e.message}` })));
71
- const names = (yield* fs.readDirectory(proxyDir)).filter((n) => n.endsWith(".mp4")).sort();
72
- const segments = yield* Effect.forEach(names, (n) => fs.readFile(path.join(proxyDir, n)));
73
- const proxy = yield* proxyInfo(asset.name, segments);
74
- // The facts and the proxy only: never the source's absolute path, which names this
75
- // machine and is wrong on any other (renders find the source themselves, see videoInputs).
76
- yield* write(importedPath(asset), new TextEncoder().encode(JSON.stringify({ ...video.info, proxy })));
77
- yield* write(videoPosterPath(asset), video.poster);
78
- break;
79
- }
80
- }
81
- return { key, entry: duration === undefined ? { fingerprint } : { fingerprint, duration }, imported: true };
82
- }), { concurrency: 4 });
83
- const manifest = { version: 1, assets: Object.fromEntries(results.map((r) => [r.key, r.entry])) };
84
- yield* fs.makeDirectory(project.assetsDir, { recursive: true });
85
- yield* fs.writeFileString(manifestPath, JSON.stringify(manifest, null, 2));
86
- const durations = new Map();
87
- for (const r of results)
88
- if (r.entry.duration !== undefined)
89
- durations.set(r.key, r.entry.duration);
90
- const report = {
91
- imported: results.filter((r) => r.imported).map((r) => r.key),
92
- skipped: results.filter((r) => !r.imported).map((r) => r.key),
93
- durations,
1
+ import { isRemoteSource } from "@tongue-core/project/media";
2
+ import { Effect, FileSystem, Path } from "effect";
3
+ import { importRemoteAsset } from "../media/prepare.js";
4
+ import { mediaOptions } from "../media/cache.js";
5
+ import { decodeImportManifest, importLocalAssets } from "./local.js";
6
+ /** Both source kinds converge on the engine's virtual asset paths. */
7
+ export const importAssets = Effect.fn("importAssets")(function* (project, assets, configuration = {}) {
8
+ const options = mediaOptions(configuration);
9
+ const local = yield* importLocalAssets(project, assets.filter((asset) => !isRemoteSource(asset.source)));
10
+ const remote = yield* Effect.forEach(assets.filter((asset) => isRemoteSource(asset.source)), (asset) => importRemoteAsset(project, asset, options, configuration.portable ?? false), { concurrency: options.concurrency ?? 4 });
11
+ const durations = new Map(local.durations);
12
+ for (const item of remote)
13
+ if (item.duration !== undefined)
14
+ durations.set(item.key, item.duration);
15
+ if (remote.length > 0) {
16
+ const fs = yield* FileSystem.FileSystem;
17
+ const path = yield* Path.Path;
18
+ const file = path.join(project.assetsDir, "manifest.json");
19
+ const manifest = yield* fs.readFileString(file).pipe(Effect.flatMap(decodeImportManifest));
20
+ const entries = { ...manifest.assets };
21
+ for (const item of remote)
22
+ entries[item.key] = { fingerprint: `remote:${item.original.hash}`, ...(item.duration === undefined ? {} : { duration: item.duration }) };
23
+ yield* fs.writeFileString(file, JSON.stringify({ version: 1, assets: entries }, null, 2));
24
+ }
25
+ return {
26
+ imported: [...local.imported, ...remote.filter((item) => !item.cached).map((item) => item.key)],
27
+ skipped: [...local.skipped, ...remote.filter((item) => item.cached).map((item) => item.key)],
28
+ durations, remoteFiles: remote.flatMap((item) => item.files), originals: remote.map((item) => item.original),
94
29
  };
95
- return report;
96
30
  });
@@ -0,0 +1,17 @@
1
+ import type { ImportReport } from "./types.js";
2
+ import type { Asset } from "@tongue-core/film";
3
+ import { Effect, FileSystem, Path, Schema } from "effect";
4
+ import { AssetFailure } from "../errors.js";
5
+ import type { Project } from "../project.js";
6
+ export declare const decodeImportManifest: (input: unknown, options?: import("effect/SchemaAST").ParseOptions) => Effect.Effect<{
7
+ readonly version: 1;
8
+ readonly assets: {
9
+ readonly [x: string]: {
10
+ readonly fingerprint: string;
11
+ readonly duration?: number | undefined;
12
+ };
13
+ };
14
+ }, Schema.SchemaError, never>;
15
+ /** What else an import depends on: a different size, or proxies made a new way, imports again. */
16
+ export declare const PROXY_VERSION = 2;
17
+ export declare const importLocalAssets: (project: Project, assets: readonly Asset[]) => Effect.Effect<ImportReport, AssetFailure | import("../errors.js").ToolFailure | import("effect/Config").ConfigError | import("effect/PlatformError").PlatformError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | Path.Path>;