@tongue-core/project 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
@@ -24,6 +24,7 @@ The SDK is that host on a machine with files and processes.
24
24
  | `@tongue-core/project/build` | the project's shape (`roleOf`), the compiler (`compileModule`), bundles, `compileProject` → `loadProject` → `extractFilm` → `writeBuild`, or `buildProject` for all four | whatever builds projects (the SDK, a backend) |
25
25
  | `@tongue-core/project/load` | `ModuleHost`, linking bundles, `openBuild` (build.json, film.json and every bundle, hashes checked), `filmFromModules` (bundles a bundler already imported) | renderers, players, Workers |
26
26
  | `@tongue-core/project/template` | the template format (`template.json`), `TemplateSource`, `projectFromTemplate` | `tongue init`, apps with template catalogs |
27
+ | `@tongue-core/project/media` | media identities, prepared-media schema, shared HTTP transport and typed failures | SDK acquisition, browser playback, host storage adapters |
27
28
 
28
29
  `@tongue-core/project` exports all of it.
29
30
 
@@ -42,21 +42,29 @@ export declare const extractFilm: (project: LoadedProject, options?: ExtractOpti
42
42
  readonly kind: "font";
43
43
  readonly name: string;
44
44
  readonly source: string;
45
+ readonly sha256?: string | undefined;
46
+ readonly prepared?: string | undefined;
45
47
  readonly italic?: string | undefined;
46
48
  } | {
47
49
  readonly kind: "image";
48
50
  readonly name: string;
49
51
  readonly source: string;
50
52
  readonly maxSize: number;
53
+ readonly sha256?: string | undefined;
54
+ readonly prepared?: string | undefined;
51
55
  } | {
52
56
  readonly kind: "video";
53
57
  readonly name: string;
54
58
  readonly source: string;
55
59
  readonly maxSize: number;
60
+ readonly sha256?: string | undefined;
61
+ readonly prepared?: string | undefined;
56
62
  } | {
57
63
  readonly kind: "audio";
58
64
  readonly name: string;
59
65
  readonly source: string;
66
+ readonly sha256?: string | undefined;
67
+ readonly prepared?: string | undefined;
60
68
  })[];
61
69
  readonly tracks: readonly {
62
70
  readonly id: string;
@@ -5,6 +5,7 @@ import type { ModuleHost } from "../load/host.js";
5
5
  import { type CompiledProject } from "./compile-project.js";
6
6
  import { type ExtractOptions } from "./extract.js";
7
7
  import type { ProjectFiles } from "./layout.js";
8
+ import type { MediaOriginal } from "../media/media.js";
8
9
  export declare const BUILD_FILE = "build.json";
9
10
  export declare const FILM_DOC_FILE = "film.json";
10
11
  /** Where a build keeps its imported assets. */
@@ -26,6 +27,7 @@ export interface WriteOptions {
26
27
  readonly tongue: string;
27
28
  /** The imported asset files the film loads, as the host found them. */
28
29
  readonly assets: ReadonlyArray<AssetFile>;
30
+ readonly originals?: ReadonlyArray<MediaOriginal>;
29
31
  }
30
32
  /** The files of a build of `compiled`, whose timeline is `doc`. */
31
33
  export declare function writeBuild(compiled: CompiledProject, doc: FilmDoc, options: WriteOptions): Build;
@@ -18,6 +18,7 @@ export function writeBuild(compiled, doc, options) {
18
18
  const manifest = {
19
19
  format: BUILD_FORMAT, version: BUILD_VERSION, tongue: options.tongue, project: compiled.manifest.name, source: compiled.source,
20
20
  film: FILM_DOC_FILE, assets: { dir: ASSETS_DIR, files },
21
+ ...(options.originals && options.originals.length > 0 ? { originals: options.originals } : {}),
21
22
  };
22
23
  return {
23
24
  manifest,
@@ -40,21 +40,29 @@ export declare const assembleFilm: (doc: {
40
40
  readonly kind: "font";
41
41
  readonly name: string;
42
42
  readonly source: string;
43
+ readonly sha256?: string | undefined;
44
+ readonly prepared?: string | undefined;
43
45
  readonly italic?: string | undefined;
44
46
  } | {
45
47
  readonly kind: "image";
46
48
  readonly name: string;
47
49
  readonly source: string;
48
50
  readonly maxSize: number;
51
+ readonly sha256?: string | undefined;
52
+ readonly prepared?: string | undefined;
49
53
  } | {
50
54
  readonly kind: "video";
51
55
  readonly name: string;
52
56
  readonly source: string;
53
57
  readonly maxSize: number;
58
+ readonly sha256?: string | undefined;
59
+ readonly prepared?: string | undefined;
54
60
  } | {
55
61
  readonly kind: "audio";
56
62
  readonly name: string;
57
63
  readonly source: string;
64
+ readonly sha256?: string | undefined;
65
+ readonly prepared?: string | undefined;
58
66
  })[];
59
67
  readonly tracks: readonly {
60
68
  readonly id: string;
@@ -262,21 +270,29 @@ export declare const sceneFilmDoc: (doc: {
262
270
  readonly kind: "font";
263
271
  readonly name: string;
264
272
  readonly source: string;
273
+ readonly sha256?: string | undefined;
274
+ readonly prepared?: string | undefined;
265
275
  readonly italic?: string | undefined;
266
276
  } | {
267
277
  readonly kind: "image";
268
278
  readonly name: string;
269
279
  readonly source: string;
270
280
  readonly maxSize: number;
281
+ readonly sha256?: string | undefined;
282
+ readonly prepared?: string | undefined;
271
283
  } | {
272
284
  readonly kind: "video";
273
285
  readonly name: string;
274
286
  readonly source: string;
275
287
  readonly maxSize: number;
288
+ readonly sha256?: string | undefined;
289
+ readonly prepared?: string | undefined;
276
290
  } | {
277
291
  readonly kind: "audio";
278
292
  readonly name: string;
279
293
  readonly source: string;
294
+ readonly sha256?: string | undefined;
295
+ readonly prepared?: string | undefined;
280
296
  })[];
281
297
  readonly tracks: readonly {
282
298
  readonly id: string;
@@ -481,21 +497,29 @@ export declare const sceneFilmDoc: (doc: {
481
497
  readonly kind: "font";
482
498
  readonly name: string;
483
499
  readonly source: string;
500
+ readonly sha256?: string | undefined;
501
+ readonly prepared?: string | undefined;
484
502
  readonly italic?: string | undefined;
485
503
  } | {
486
504
  readonly kind: "image";
487
505
  readonly name: string;
488
506
  readonly source: string;
489
507
  readonly maxSize: number;
508
+ readonly sha256?: string | undefined;
509
+ readonly prepared?: string | undefined;
490
510
  } | {
491
511
  readonly kind: "video";
492
512
  readonly name: string;
493
513
  readonly source: string;
494
514
  readonly maxSize: number;
515
+ readonly sha256?: string | undefined;
516
+ readonly prepared?: string | undefined;
495
517
  } | {
496
518
  readonly kind: "audio";
497
519
  readonly name: string;
498
520
  readonly source: string;
521
+ readonly sha256?: string | undefined;
522
+ readonly prepared?: string | undefined;
499
523
  })[];
500
524
  readonly tracks: readonly {
501
525
  readonly id: string;
@@ -1,21 +1,22 @@
1
- import { audio, Clip, Film, font, fromJson, image, narration, SchemaError, Track, Transition, video, } from "@tongue-core/film";
1
+ import { audio, Clip, collectAssetsSync, Film, font, fromJson, image, narration, SchemaError, Track, Transition, video, } from "@tongue-core/film";
2
2
  import { setOrigin } from "@tongue-core/film/model";
3
3
  import { Effect } from "effect";
4
4
  import { DocError } from "../errors.js";
5
5
  import { docError } from "./issues.js";
6
6
  /** The film's assets, made from the document: each once per kind and name, in its order. */
7
7
  export function assetsOf(doc) {
8
- return doc.assets.map((a) => {
8
+ return collectAssetsSync(() => doc.assets.map((a) => {
9
+ const identity = { ...(a.sha256 === undefined ? {} : { sha256: a.sha256 }), ...(a.prepared === undefined ? {} : { prepared: a.prepared }) };
9
10
  switch (a.kind) {
10
11
  case "font": {
11
12
  const italic = a.italic === undefined ? undefined : doc.assets.find((other) => other.kind === "font" && other.name === a.italic);
12
- return font(a.source, italic ? { name: a.name, italic: italic.source } : { name: a.name });
13
+ return font(a.source, { name: a.name, ...identity, ...(italic ? { italic: italic.source } : {}) });
13
14
  }
14
- case "image": return image(a.source, { name: a.name, maxSize: a.maxSize });
15
- case "video": return video(a.source, { name: a.name, maxSize: a.maxSize });
16
- case "audio": return audio(a.source, { name: a.name });
15
+ case "image": return image(a.source, { name: a.name, maxSize: a.maxSize, ...identity });
16
+ case "video": return video(a.source, { name: a.name, maxSize: a.maxSize, ...identity });
17
+ case "audio": return audio(a.source, { name: a.name, ...identity });
17
18
  }
18
- });
19
+ })).value;
19
20
  }
20
21
  /** Finds the document's assets by kind and name. */
21
22
  export function assetLookup(assets) {
@@ -1,6 +1,6 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  export declare const BUILD_FORMAT = "tongue.build";
3
- export declare const BUILD_VERSION = 1;
3
+ export declare const BUILD_VERSION = 2;
4
4
  /** One imported asset file: fonts/inter.ttf, images/dusk.qoi, video/aurora/info.json… */
5
5
  export declare const AssetFile: Schema.Struct<{
6
6
  /** Relative to the build's assets folder. */
@@ -8,11 +8,13 @@ export declare const AssetFile: Schema.Struct<{
8
8
  readonly bytes: Schema.Int;
9
9
  /** SHA-256 of the file, hex. */
10
10
  readonly hash: Schema.String;
11
+ /** Absent for a file packaged in the build. */
12
+ readonly url: Schema.optionalKey<Schema.String>;
11
13
  }>;
12
14
  export type AssetFile = typeof AssetFile.Type;
13
15
  export declare const BuildDoc: Schema.Struct<{
14
16
  readonly format: Schema.Literal<"tongue.build">;
15
- readonly version: Schema.Literal<1>;
17
+ readonly version: Schema.Literals<readonly [1, 2]>;
16
18
  /** The Tongue version that built it. */
17
19
  readonly tongue: Schema.String;
18
20
  /** The project's name, from tongue.json. */
@@ -30,25 +32,43 @@ export declare const BuildDoc: Schema.Struct<{
30
32
  readonly bytes: Schema.Int;
31
33
  /** SHA-256 of the file, hex. */
32
34
  readonly hash: Schema.String;
35
+ /** Absent for a file packaged in the build. */
36
+ readonly url: Schema.optionalKey<Schema.String>;
33
37
  }>>;
34
38
  }>;
39
+ /** Pinned remote originals: enough to render a build on another machine. */
40
+ readonly originals: Schema.optionalKey<Schema.$Array<Schema.Struct<{
41
+ readonly kind: Schema.Literals<readonly ["font", "image", "audio", "video"]>;
42
+ readonly name: Schema.String;
43
+ readonly source: Schema.String;
44
+ readonly hash: Schema.String;
45
+ readonly bytes: Schema.Int;
46
+ }>>>;
35
47
  }>;
36
48
  export type BuildDoc = typeof BuildDoc.Type;
37
49
  export declare const formatBuildDoc: (doc: BuildDoc) => string;
38
50
  /** build.json's text as a checked document. */
39
51
  export declare const parseBuildDoc: (text: string) => Effect.Effect<{
40
- readonly format: "tongue.build";
41
- readonly version: 1;
42
- readonly tongue: string;
43
- readonly project: string;
44
- readonly source: string;
45
- readonly film: string;
46
52
  readonly assets: {
47
53
  readonly dir: string;
48
54
  readonly files: readonly {
49
55
  readonly path: string;
50
- readonly bytes: number;
51
56
  readonly hash: string;
57
+ readonly bytes: number;
58
+ readonly url?: string | undefined;
52
59
  }[];
53
60
  };
61
+ readonly source: string;
62
+ readonly format: "tongue.build";
63
+ readonly version: 2 | 1;
64
+ readonly film: string;
65
+ readonly tongue: string;
66
+ readonly project: string;
67
+ readonly originals?: readonly {
68
+ readonly kind: "font" | "image" | "video" | "audio";
69
+ readonly name: string;
70
+ readonly source: string;
71
+ readonly hash: string;
72
+ readonly bytes: number;
73
+ }[] | undefined;
54
74
  }, import("../errors.js").DocError, never>;
@@ -1,22 +1,25 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import { docError, schemaProblems } from "./issues.js";
3
+ import { MediaOriginal, MediaPath, MediaUrl } from "../media/media.js";
3
4
  // build.json: the entry point of a build. What made it, from which sources, where its film.json is
4
5
  // (the scene bundles are listed in film.json), and the imported asset files it needs, with their
5
6
  // sizes and hashes: what a player loads, shows progress for, and caches by.
6
7
  export const BUILD_FORMAT = "tongue.build";
7
- export const BUILD_VERSION = 1;
8
+ export const BUILD_VERSION = 2;
8
9
  const HASH = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/, { message: "a SHA-256 hash (64 hex digits)" }));
9
10
  /** One imported asset file: fonts/inter.ttf, images/dusk.qoi, video/aurora/info.json… */
10
11
  export const AssetFile = Schema.Struct({
11
12
  /** Relative to the build's assets folder. */
12
- path: Schema.String,
13
+ path: MediaPath,
13
14
  bytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
14
15
  /** SHA-256 of the file, hex. */
15
16
  hash: HASH,
17
+ /** Absent for a file packaged in the build. */
18
+ url: Schema.optionalKey(MediaUrl),
16
19
  });
17
20
  export const BuildDoc = Schema.Struct({
18
21
  format: Schema.Literal(BUILD_FORMAT),
19
- version: Schema.Literal(BUILD_VERSION),
22
+ version: Schema.Literals([1, BUILD_VERSION]),
20
23
  /** The Tongue version that built it. */
21
24
  tongue: Schema.String,
22
25
  /** The project's name, from tongue.json. */
@@ -27,11 +30,29 @@ export const BuildDoc = Schema.Struct({
27
30
  film: Schema.String,
28
31
  /** The imported asset files the film loads: where they are (relative to the build), and each one. */
29
32
  assets: Schema.Struct({ dir: Schema.String, files: Schema.Array(AssetFile) }),
33
+ /** Pinned remote originals: enough to render a build on another machine. */
34
+ originals: Schema.optionalKey(Schema.Array(MediaOriginal)),
30
35
  });
31
36
  export const formatBuildDoc = (doc) => `${JSON.stringify(doc, null, 2)}\n`;
32
37
  const decode = Schema.decodeUnknownEffect(BuildDoc);
33
38
  /** build.json's text as a checked document. */
34
39
  export const parseBuildDoc = Effect.fn("parseBuildDoc")(function* (text) {
35
40
  const json = yield* Effect.try({ try: () => JSON.parse(text), catch: (e) => docError("build.json isn't JSON", [{ path: "", message: e instanceof Error ? e.message : String(e) }]) });
36
- return yield* decode(json, { errors: "all" }).pipe(Effect.mapError((e) => docError("build.json isn't a version 1 build", schemaProblems(e))));
41
+ const doc = yield* decode(json, { errors: "all" }).pipe(Effect.mapError((e) => docError("build.json isn't a supported build", schemaProblems(e))));
42
+ if (doc.version === 1 && (doc.originals !== undefined || doc.assets.files.some((file) => file.url !== undefined)))
43
+ return yield* docError("Remote assets require build format version 2", []);
44
+ const paths = new Set();
45
+ for (const file of doc.assets.files) {
46
+ if (paths.has(file.path))
47
+ return yield* docError("build.json has duplicate asset paths", [{ path: file.path, message: "duplicate asset file" }]);
48
+ paths.add(file.path);
49
+ }
50
+ const originals = new Set();
51
+ for (const original of doc.originals ?? []) {
52
+ const key = `${original.kind}:${original.name}`;
53
+ if (originals.has(key))
54
+ return yield* docError("build.json has duplicate original identities", [{ path: "originals", message: key }]);
55
+ originals.add(key);
56
+ }
57
+ return doc;
37
58
  });
@@ -34,21 +34,29 @@ export declare const decodeFilmDoc: (json: unknown) => Effect.Effect<{
34
34
  readonly kind: "font";
35
35
  readonly name: string;
36
36
  readonly source: string;
37
+ readonly sha256?: string | undefined;
38
+ readonly prepared?: string | undefined;
37
39
  readonly italic?: string | undefined;
38
40
  } | {
39
41
  readonly kind: "image";
40
42
  readonly name: string;
41
43
  readonly source: string;
42
44
  readonly maxSize: number;
45
+ readonly sha256?: string | undefined;
46
+ readonly prepared?: string | undefined;
43
47
  } | {
44
48
  readonly kind: "video";
45
49
  readonly name: string;
46
50
  readonly source: string;
47
51
  readonly maxSize: number;
52
+ readonly sha256?: string | undefined;
53
+ readonly prepared?: string | undefined;
48
54
  } | {
49
55
  readonly kind: "audio";
50
56
  readonly name: string;
51
57
  readonly source: string;
58
+ readonly sha256?: string | undefined;
59
+ readonly prepared?: string | undefined;
52
60
  })[];
53
61
  readonly tracks: readonly {
54
62
  readonly id: string;
@@ -255,21 +263,29 @@ export declare const parseFilmDoc: (text: string) => Effect.Effect<{
255
263
  readonly kind: "font";
256
264
  readonly name: string;
257
265
  readonly source: string;
266
+ readonly sha256?: string | undefined;
267
+ readonly prepared?: string | undefined;
258
268
  readonly italic?: string | undefined;
259
269
  } | {
260
270
  readonly kind: "image";
261
271
  readonly name: string;
262
272
  readonly source: string;
263
273
  readonly maxSize: number;
274
+ readonly sha256?: string | undefined;
275
+ readonly prepared?: string | undefined;
264
276
  } | {
265
277
  readonly kind: "video";
266
278
  readonly name: string;
267
279
  readonly source: string;
268
280
  readonly maxSize: number;
281
+ readonly sha256?: string | undefined;
282
+ readonly prepared?: string | undefined;
269
283
  } | {
270
284
  readonly kind: "audio";
271
285
  readonly name: string;
272
286
  readonly source: string;
287
+ readonly sha256?: string | undefined;
288
+ readonly prepared?: string | undefined;
273
289
  })[];
274
290
  readonly tracks: readonly {
275
291
  readonly id: string;
@@ -49,19 +49,27 @@ export declare const AssetDoc: Schema.Union<readonly [Schema.Struct<{
49
49
  readonly kind: Schema.Literal<"font">;
50
50
  readonly name: Schema.String;
51
51
  readonly source: Schema.String;
52
+ readonly sha256: Schema.optionalKey<Schema.String>;
53
+ readonly prepared: Schema.optionalKey<Schema.String>;
52
54
  /** The name of the font asset `fontStyle: "italic"` uses. */
53
55
  readonly italic: Schema.optionalKey<Schema.String>;
54
56
  }>, Schema.Struct<{
57
+ readonly sha256: Schema.optionalKey<Schema.String>;
58
+ readonly prepared: Schema.optionalKey<Schema.String>;
55
59
  readonly kind: Schema.Literal<"image">;
56
60
  readonly name: Schema.String;
57
61
  readonly source: Schema.String;
58
62
  readonly maxSize: Schema.Int;
59
63
  }>, Schema.Struct<{
64
+ readonly sha256: Schema.optionalKey<Schema.String>;
65
+ readonly prepared: Schema.optionalKey<Schema.String>;
60
66
  readonly kind: Schema.Literal<"video">;
61
67
  readonly name: Schema.String;
62
68
  readonly source: Schema.String;
63
69
  readonly maxSize: Schema.Int;
64
70
  }>, Schema.Struct<{
71
+ readonly sha256: Schema.optionalKey<Schema.String>;
72
+ readonly prepared: Schema.optionalKey<Schema.String>;
65
73
  readonly kind: Schema.Literal<"audio">;
66
74
  readonly name: Schema.String;
67
75
  readonly source: Schema.String;
@@ -674,19 +682,27 @@ export declare const FilmDoc: Schema.Struct<{
674
682
  readonly kind: Schema.Literal<"font">;
675
683
  readonly name: Schema.String;
676
684
  readonly source: Schema.String;
685
+ readonly sha256: Schema.optionalKey<Schema.String>;
686
+ readonly prepared: Schema.optionalKey<Schema.String>;
677
687
  /** The name of the font asset `fontStyle: "italic"` uses. */
678
688
  readonly italic: Schema.optionalKey<Schema.String>;
679
689
  }>, Schema.Struct<{
690
+ readonly sha256: Schema.optionalKey<Schema.String>;
691
+ readonly prepared: Schema.optionalKey<Schema.String>;
680
692
  readonly kind: Schema.Literal<"image">;
681
693
  readonly name: Schema.String;
682
694
  readonly source: Schema.String;
683
695
  readonly maxSize: Schema.Int;
684
696
  }>, Schema.Struct<{
697
+ readonly sha256: Schema.optionalKey<Schema.String>;
698
+ readonly prepared: Schema.optionalKey<Schema.String>;
685
699
  readonly kind: Schema.Literal<"video">;
686
700
  readonly name: Schema.String;
687
701
  readonly source: Schema.String;
688
702
  readonly maxSize: Schema.Int;
689
703
  }>, Schema.Struct<{
704
+ readonly sha256: Schema.optionalKey<Schema.String>;
705
+ readonly prepared: Schema.optionalKey<Schema.String>;
690
706
  readonly kind: Schema.Literal<"audio">;
691
707
  readonly name: Schema.String;
692
708
  readonly source: Schema.String;
@@ -56,12 +56,15 @@ const FontDoc = Schema.Struct({
56
56
  kind: Schema.Literal("font"),
57
57
  name: Name,
58
58
  source: Schema.String,
59
+ sha256: Schema.optionalKey(Schema.String),
60
+ prepared: Schema.optionalKey(Schema.String),
59
61
  /** The name of the font asset `fontStyle: "italic"` uses. */
60
62
  italic: Schema.optionalKey(Name),
61
63
  });
62
- const ImageDoc = Schema.Struct({ kind: Schema.Literal("image"), name: Name, source: Schema.String, maxSize: Pixels });
63
- const VideoDoc = Schema.Struct({ kind: Schema.Literal("video"), name: Name, source: Schema.String, maxSize: Pixels });
64
- const AudioDoc = Schema.Struct({ kind: Schema.Literal("audio"), name: Name, source: Schema.String });
64
+ const SourceOptions = { sha256: Schema.optionalKey(Schema.String), prepared: Schema.optionalKey(Schema.String) };
65
+ const ImageDoc = Schema.Struct({ kind: Schema.Literal("image"), name: Name, source: Schema.String, maxSize: Pixels, ...SourceOptions });
66
+ const VideoDoc = Schema.Struct({ kind: Schema.Literal("video"), name: Name, source: Schema.String, maxSize: Pixels, ...SourceOptions });
67
+ const AudioDoc = Schema.Struct({ kind: Schema.Literal("audio"), name: Name, source: Schema.String, ...SourceOptions });
65
68
  /** A file the film loads; `source` is relative to the project. Names are unique per kind. */
66
69
  export const AssetDoc = Schema.Union([FontDoc, ImageDoc, VideoDoc, AudioDoc]);
67
70
  // ---------------------------------------------------------------- clips
@@ -39,21 +39,29 @@ export declare const filmDocOf: (plan: FilmPlan, background: string, scenes: rea
39
39
  readonly kind: "font";
40
40
  readonly name: string;
41
41
  readonly source: string;
42
+ readonly sha256?: string | undefined;
43
+ readonly prepared?: string | undefined;
42
44
  readonly italic?: string | undefined;
43
45
  } | {
44
46
  readonly kind: "image";
45
47
  readonly name: string;
46
48
  readonly source: string;
47
49
  readonly maxSize: number;
50
+ readonly sha256?: string | undefined;
51
+ readonly prepared?: string | undefined;
48
52
  } | {
49
53
  readonly kind: "video";
50
54
  readonly name: string;
51
55
  readonly source: string;
52
56
  readonly maxSize: number;
57
+ readonly sha256?: string | undefined;
58
+ readonly prepared?: string | undefined;
53
59
  } | {
54
60
  readonly kind: "audio";
55
61
  readonly name: string;
56
62
  readonly source: string;
63
+ readonly sha256?: string | undefined;
64
+ readonly prepared?: string | undefined;
57
65
  })[];
58
66
  readonly tracks: readonly {
59
67
  readonly id: string;
@@ -88,11 +88,12 @@ function kitDoc(kit, scenes) {
88
88
  return { kit: jsonOf(first.scene.kitSchema, kit, null, "The film's kit"), kitSchema: first.scene.kitSchema.jsonSchema };
89
89
  }
90
90
  const assetDoc = (a) => {
91
+ const identity = { ...(a.sha256 === undefined ? {} : { sha256: a.sha256 }), ...(a.prepared === undefined ? {} : { prepared: a.prepared }) };
91
92
  switch (a.kind) {
92
- case "font": return a.italic ? { kind: "font", name: a.name, source: a.source, italic: a.italic.name } : { kind: "font", name: a.name, source: a.source };
93
- case "image": return { kind: "image", name: a.name, source: a.source, maxSize: a.maxSize };
94
- case "video": return { kind: "video", name: a.name, source: a.source, maxSize: a.maxSize };
95
- case "audio": return { kind: "audio", name: a.name, source: a.source };
93
+ case "font": return { kind: "font", name: a.name, source: a.source, ...identity, ...(a.italic ? { italic: a.italic.name } : {}) };
94
+ case "image": return { kind: "image", name: a.name, source: a.source, maxSize: a.maxSize, ...identity };
95
+ case "video": return { kind: "video", name: a.name, source: a.source, maxSize: a.maxSize, ...identity };
96
+ case "audio": return { kind: "audio", name: a.name, source: a.source, ...identity };
96
97
  }
97
98
  };
98
99
  /** A film planned with `stableIds` as film.json. `background` is the color as the film wrote it. */
@@ -33,21 +33,29 @@ export declare const planFilmDoc: (doc: {
33
33
  readonly kind: "font";
34
34
  readonly name: string;
35
35
  readonly source: string;
36
+ readonly sha256?: string | undefined;
37
+ readonly prepared?: string | undefined;
36
38
  readonly italic?: string | undefined;
37
39
  } | {
38
40
  readonly kind: "image";
39
41
  readonly name: string;
40
42
  readonly source: string;
41
43
  readonly maxSize: number;
44
+ readonly sha256?: string | undefined;
45
+ readonly prepared?: string | undefined;
42
46
  } | {
43
47
  readonly kind: "video";
44
48
  readonly name: string;
45
49
  readonly source: string;
46
50
  readonly maxSize: number;
51
+ readonly sha256?: string | undefined;
52
+ readonly prepared?: string | undefined;
47
53
  } | {
48
54
  readonly kind: "audio";
49
55
  readonly name: string;
50
56
  readonly source: string;
57
+ readonly sha256?: string | undefined;
58
+ readonly prepared?: string | undefined;
51
59
  })[];
52
60
  readonly tracks: readonly {
53
61
  readonly id: string;
package/dist/load/host.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as film from "@tongue-core/film";
2
- import { collectAssets } from "@tongue-core/film";
2
+ import { collectAssetsSync } from "@tongue-core/film";
3
3
  import * as jsxDev from "@tongue-core/film/jsx-dev-runtime";
4
4
  import { Effect, Predicate } from "effect";
5
5
  import { LoadError } from "../errors.js";
@@ -27,8 +27,8 @@ export function linkModule(module, bundle, extra = {}) {
27
27
  /** Loads and links a bundle, keeping the assets its modules declare while they run. */
28
28
  export const linkBundle = Effect.fn("linkBundle")(function* (host, bundle, extra = {}) {
29
29
  const module = yield* loadBundle(host, bundle);
30
- const { value, assets } = yield* Effect.tryPromise({
31
- try: () => collectAssets(async () => linkModule(module, bundle, extra)),
30
+ const { value, assets } = yield* Effect.try({
31
+ try: () => collectAssetsSync(() => linkModule(module, bundle, extra)),
32
32
  catch: (e) => (e instanceof LoadError ? e : new LoadError({ file: bundle.entry, message: e instanceof Error ? e.message : String(e) })),
33
33
  });
34
34
  return { exports: value, assets };
@@ -4,12 +4,18 @@ import { parseBuildDoc } from "../doc/build-doc.js";
4
4
  import { parseFilmDoc } from "../doc/check.js";
5
5
  import { sha256 } from "../hash.js";
6
6
  import { loadScene } from "./scene.js";
7
+ import { isRemoteSource } from "../media/media.js";
7
8
  const read = (reader, path) => Effect.tryPromise({ try: () => reader(path), catch: (e) => new LoadError({ file: path, message: `${path} can't be read from the build: ${e instanceof Error ? e.message : String(e)}` }) });
8
9
  /** Opens the build `reader` reads. `manifest` is its build.json (another name opens a variant of
9
10
  * the build, such as one scene on its own, whose own film.json it names). */
10
11
  export const openBuild = Effect.fn("openBuild")(function* (reader, host, manifestFile = "build.json") {
11
12
  const manifest = yield* parseBuildDoc(yield* read(reader, manifestFile));
12
13
  const doc = yield* parseFilmDoc(yield* read(reader, manifest.film));
14
+ for (const asset of doc.assets.filter((asset) => isRemoteSource(asset.source))) {
15
+ const original = manifest.originals?.find((item) => item.kind === asset.kind && item.name === asset.name);
16
+ if (!original || original.source !== asset.source || (asset.sha256 !== undefined && asset.sha256 !== original.hash))
17
+ return yield* new DocError({ problems: [{ path: `assets.${asset.name}`, message: "missing or mismatched pinned original" }], message: `Remote ${asset.kind} "${asset.name}" needs a matching pinned original in build.json.` });
18
+ }
13
19
  const scenes = new Map();
14
20
  for (const entry of doc.scenes) {
15
21
  const code = yield* read(reader, entry.bundle);
@@ -0,0 +1,67 @@
1
+ import { Effect, Schema } from "effect";
2
+ /** Media identity is independent of its location and of the asset's name in a film. */
3
+ export declare const MediaHash: Schema.String;
4
+ export declare const MediaKind: Schema.Literals<readonly ["font", "image", "audio", "video"]>;
5
+ export declare const MediaUrl: Schema.String;
6
+ export declare const MediaPath: Schema.String;
7
+ export declare const MediaOriginal: Schema.Struct<{
8
+ readonly kind: Schema.Literals<readonly ["font", "image", "audio", "video"]>;
9
+ readonly name: Schema.String;
10
+ readonly source: Schema.String;
11
+ readonly hash: Schema.String;
12
+ readonly bytes: Schema.Int;
13
+ }>;
14
+ export type MediaOriginal = typeof MediaOriginal.Type;
15
+ /** Paths use the canonical asset name "asset"; a project binds them to its own asset names. */
16
+ export declare const PreparedFile: Schema.Struct<{
17
+ readonly path: Schema.String;
18
+ readonly hash: Schema.String;
19
+ readonly bytes: Schema.Int;
20
+ readonly url: Schema.String;
21
+ }>;
22
+ export declare const PreparedMedia: Schema.Struct<{
23
+ readonly format: Schema.Literal<"tongue.media">;
24
+ readonly version: Schema.Literal<1>;
25
+ readonly kind: Schema.Literals<readonly ["font", "image", "audio", "video"]>;
26
+ readonly source: Schema.Struct<{
27
+ readonly url: Schema.String;
28
+ readonly hash: Schema.String;
29
+ readonly bytes: Schema.Int;
30
+ }>;
31
+ readonly duration: Schema.optionalKey<Schema.Finite>;
32
+ /** Import recipe version and sizing, so incompatible previews are never reused. */
33
+ readonly recipe: Schema.String;
34
+ readonly files: Schema.$Array<Schema.Struct<{
35
+ readonly path: Schema.String;
36
+ readonly hash: Schema.String;
37
+ readonly bytes: Schema.Int;
38
+ readonly url: Schema.String;
39
+ }>>;
40
+ }>;
41
+ export type PreparedMedia = typeof PreparedMedia.Type;
42
+ declare const MediaFailure_base: Schema.Class<MediaFailure, Schema.TaggedStruct<"MediaFailure", {
43
+ readonly asset: Schema.String;
44
+ readonly source: Schema.String;
45
+ readonly reason: Schema.Literals<readonly ["network", "http", "integrity", "invalid", "storage"]>;
46
+ readonly status: Schema.optionalKey<Schema.Number>;
47
+ readonly retryable: Schema.Boolean;
48
+ readonly message: Schema.String;
49
+ readonly retryAfterMs: Schema.optionalKey<Schema.Number>;
50
+ }>, import("effect/Cause").YieldableError>;
51
+ export declare class MediaFailure extends MediaFailure_base {
52
+ }
53
+ export interface MediaRequestOptions {
54
+ /** A host can refresh signed URLs or supply authorization here. Never serialized into a build. */
55
+ readonly request?: (url: string, signal: AbortSignal, headers?: Readonly<Record<string, string>>) => Promise<Response>;
56
+ readonly headers?: Readonly<Record<string, string>>;
57
+ /** Retries after the first attempt. Default 2. */
58
+ readonly retries?: number;
59
+ readonly timeoutMs?: number;
60
+ readonly retryDelayMs?: number;
61
+ }
62
+ export declare const isRemoteSource: (source: string) => boolean;
63
+ /** The response body is consumed inside the retry boundary, including interrupted transfers. */
64
+ export declare const readMedia: <A>(asset: string, url: string, consume: (response: Response, signal: AbortSignal) => Promise<A>, options?: MediaRequestOptions) => Effect.Effect<A, MediaFailure>;
65
+ /** Shared virtual path binding keeps the engine independent of URLs and storage. */
66
+ export declare function bindMediaPath(path: string, kind: string, name: string): string;
67
+ export {};
@@ -0,0 +1,76 @@
1
+ import { Effect, Schema } from "effect";
2
+ /** Media identity is independent of its location and of the asset's name in a film. */
3
+ export const MediaHash = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/));
4
+ export const MediaKind = Schema.Literals(["font", "image", "audio", "video"]);
5
+ export const MediaUrl = Schema.String.check(Schema.makeFilter((value) => {
6
+ try {
7
+ const url = new URL(value);
8
+ return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }));
14
+ export const MediaPath = Schema.String.check(Schema.makeFilter((value) => value.length > 0 && !value.startsWith("/") && !value.includes("\\") && value.split("/").every((part) => part !== ".." && part !== "." && part.length > 0)));
15
+ export const MediaOriginal = Schema.Struct({
16
+ kind: MediaKind, name: Schema.String, source: MediaUrl, hash: MediaHash,
17
+ bytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
18
+ });
19
+ /** Paths use the canonical asset name "asset"; a project binds them to its own asset names. */
20
+ export const PreparedFile = Schema.Struct({
21
+ path: MediaPath, hash: MediaHash, bytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), url: MediaUrl,
22
+ });
23
+ export const PreparedMedia = Schema.Struct({
24
+ format: Schema.Literal("tongue.media"), version: Schema.Literal(1), kind: MediaKind,
25
+ source: Schema.Struct({ url: MediaUrl, hash: MediaHash, bytes: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) }),
26
+ duration: Schema.optionalKey(Schema.Finite.check(Schema.isGreaterThan(0))),
27
+ /** Import recipe version and sizing, so incompatible previews are never reused. */
28
+ recipe: Schema.String,
29
+ files: Schema.Array(PreparedFile),
30
+ });
31
+ export class MediaFailure extends Schema.TaggedError()("MediaFailure", {
32
+ asset: Schema.String, source: Schema.String,
33
+ reason: Schema.Literals(["network", "http", "integrity", "invalid", "storage"]),
34
+ status: Schema.optionalKey(Schema.Number), retryable: Schema.Boolean, message: Schema.String,
35
+ retryAfterMs: Schema.optionalKey(Schema.Number),
36
+ }) {
37
+ }
38
+ export const isRemoteSource = (source) => /^https?:\/\//i.test(source);
39
+ const message = (error) => error instanceof Error ? error.message : String(error);
40
+ /** The response body is consumed inside the retry boundary, including interrupted transfers. */
41
+ export const readMedia = (asset, url, consume, options = {}) => Effect.gen(function* () {
42
+ if (!Schema.is(MediaUrl)(url))
43
+ return yield* new MediaFailure({ asset, source: url, reason: "invalid", retryable: false, message: `${asset}: media requires an absolute HTTP(S) URL without embedded credentials.` });
44
+ const retries = Math.max(0, Math.min(10, options.retries ?? 2));
45
+ for (let attempt = 0;; attempt++) {
46
+ const result = yield* Effect.tryPromise({
47
+ try: async (signal) => {
48
+ const abort = AbortSignal.any([signal, AbortSignal.timeout(options.timeoutMs ?? 120_000)]);
49
+ const response = await (options.request ?? ((source, cancel, headers) => fetch(source, { signal: cancel, headers })))(url, abort, options.headers);
50
+ if (!response.ok && response.status !== 304) {
51
+ await response.body?.cancel();
52
+ const retry = response.headers.get("retry-after");
53
+ const delay = retry === null ? 0 : /^\d+$/.test(retry) ? Number(retry) * 1000 : Math.max(0, Date.parse(retry) - Date.now());
54
+ throw new MediaFailure({ asset, source: url, reason: "http", status: response.status, retryable: response.status === 408 || response.status === 429 || response.status >= 500, ...(Number.isFinite(delay) && delay > 0 ? { retryAfterMs: Math.min(60_000, delay) } : {}), message: `${asset}: ${url} returned HTTP ${response.status}.` });
55
+ }
56
+ return consume(response, abort);
57
+ },
58
+ catch: (error) => error instanceof MediaFailure ? error : new MediaFailure({ asset, source: url, reason: "network", retryable: true, message: `${asset}: ${url} could not be downloaded: ${message(error)}` }),
59
+ }).pipe(Effect.result);
60
+ if (result._tag === "Success")
61
+ return result.success;
62
+ if (!result.failure.retryable || attempt >= retries)
63
+ return yield* result.failure;
64
+ yield* Effect.sleep(result.failure.retryAfterMs ?? Math.min(5000, (options.retryDelayMs ?? 200) * 2 ** attempt));
65
+ }
66
+ });
67
+ /** Shared virtual path binding keeps the engine independent of URLs and storage. */
68
+ export function bindMediaPath(path, kind, name) {
69
+ if (kind === "font")
70
+ return path.replace(/^fonts\/asset(?=\.)/, `fonts/${name}`);
71
+ if (kind === "image")
72
+ return path.replace(/^images\/asset(?=\.)/, `images/${name}`);
73
+ if (kind === "audio")
74
+ return path.replace(/^audio\/asset(?=[/.])/, `audio/${name}`);
75
+ return path.replace(/^video\/asset\//, `video/${name}/`);
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tongue-core/project",
3
- "version": "0.1.0-rc.8",
3
+ "version": "0.1.0-rc.9",
4
4
  "description": "Film projects: their shape, building one into film.json and a bundle per scene, film.json's schema and checks, and turning it back into a film. No Bun, Node or ffmpeg: runs in Bun, browsers and Workers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,6 +31,10 @@
31
31
  "./template": {
32
32
  "types": "./dist/template/index.d.ts",
33
33
  "default": "./dist/template/index.js"
34
+ },
35
+ "./media": {
36
+ "types": "./dist/media/media.d.ts",
37
+ "default": "./dist/media/media.js"
34
38
  }
35
39
  },
36
40
  "main": "./dist/index.js",
@@ -40,7 +44,7 @@
40
44
  "README.md"
41
45
  ],
42
46
  "dependencies": {
43
- "@tongue-core/film": "0.1.0-rc.8",
47
+ "@tongue-core/film": "0.1.0-rc.9",
44
48
  "sucrase": "3.35.1"
45
49
  },
46
50
  "peerDependencies": {