@tongue-core/ui 0.1.0-rc.3 → 0.1.0-rc.5

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
@@ -43,8 +43,16 @@ sets its own `object-fit`, the player sets `contain`, so a box of another shape
43
43
  frame instead of stretching it.
44
44
 
45
45
  A build is static files: `build.json`, `film.json`, `scenes/*.js`, `assets/…` (`tongue build`
46
- writes one). The player fetches the manifests and bundles, then the asset files frames need, nearest
47
- the playhead first, each checked against its hash. **A frame is drawn only once every file it needs
46
+ writes one). The player fetches them from the build's folder or, given `read` instead of `build`,
47
+ reads them a file at a time from wherever the build is kept (a Git commit, memory, an archive):
48
+
49
+ ```ts
50
+ // Each path as the build gives it: "build.json", "scenes/title.5ebf1e9a05d5.js", "assets/fonts/inter.ttf"…
51
+ await player.load({ read: (path, signal) => commit.bytes(path, signal) });
52
+ ```
53
+
54
+ Either way it reads the manifests and bundles, then the asset files frames need, nearest the
55
+ playhead first, each checked against its hash. **A frame is drawn only once every file it needs
48
56
  is there**; until then the last frame stays up. Video plays from the proxy segments import makes
49
57
  (H.264, decoded with WebCodecs in a worker), sound from the soundtrack mixed a few seconds at a time.
50
58
  Frames, video frames and sound are byte-for-byte what Bun makes from the same build.
@@ -1,6 +1,6 @@
1
1
  export { createPlayer, type Player, type PlayerOptions, type PlayerEvents, type PlayerEvent, type Readiness, type Shown, type LoadedFilm, type Pixels, type Mix } from "./player.js";
2
2
  export { Cancelled, type FrameResult } from "./pool.js";
3
- export { openSource, buildBase, SourceError, type BuildSource, type OpenedSource } from "./source.js";
3
+ export { openFilm, openSource, openFiles, buildBase, SourceError, type FilmSource, type BuildSource, type FileSource, type OpenedBuild, type OpenedSource } from "./source.js";
4
4
  export { FilmNeeds, type Span, type Range } from "./needs.js";
5
5
  export { AssetLoader, type AssetFileInfo, type AssetStatus, type AssetProgress, type FetchBytes } from "./assets.js";
6
6
  export { FrameCache, type Cached, type Closable } from "./frames.js";
@@ -1,10 +1,11 @@
1
1
  // @tongue-core/ui/player: a film's player, without React or Effect. It opens a build from wherever it is
2
- // served (build.json, film.json, the scene bundles, then the asset files frames need), draws exact
3
- // frames with the engine in Web Workers, and plays them with the soundtrack. The worker script is
4
- // @tongue-core/ui/player/worker; bundle it and pass its URL.
2
+ // served, or reads it a file at a time from wherever it is kept (build.json, film.json, the scene
3
+ // bundles, then the asset files frames need), draws exact frames with the engine in Web Workers, and
4
+ // plays them with the soundtrack. The worker script is @tongue-core/ui/player/worker; bundle it and
5
+ // pass its URL.
5
6
  export { createPlayer } from "./player.js";
6
7
  export { Cancelled } from "./pool.js";
7
- export { openSource, buildBase, SourceError } from "./source.js";
8
+ export { openFilm, openSource, openFiles, buildBase, SourceError } from "./source.js";
8
9
  export { FilmNeeds } from "./needs.js";
9
10
  export { AssetLoader } from "./assets.js";
10
11
  export { FrameCache } from "./frames.js";
@@ -3,7 +3,7 @@ import type { BuildDoc, FilmDoc } from "@tongue-core/project/doc";
3
3
  import { type AssetProgress } from "./assets.js";
4
4
  import { type Range } from "./needs.js";
5
5
  import { type Scales } from "./schedule.js";
6
- import { type BuildSource } from "./source.js";
6
+ import { type FilmSource } from "./source.js";
7
7
  import { type PlayState } from "./transport.js";
8
8
  export interface PlayerOptions {
9
9
  /** Where frames are drawn. Its pixel size follows the film's size at the still scale. */
@@ -72,8 +72,9 @@ export interface Mix {
72
72
  readonly right: Float32Array;
73
73
  }
74
74
  export interface Player {
75
- /** Loads a build (again, after a rebuild: the playhead and range are kept, unchanged files aren't fetched again). */
76
- load(source: BuildSource): Promise<LoadedFilm>;
75
+ /** Loads a build, served as files (`{ build }`) or read a file at a time (`{ read }`); again after a
76
+ * rebuild or for another version: the playhead and range are kept, unchanged files aren't read again. */
77
+ load(source: FilmSource): Promise<LoadedFilm>;
77
78
  /** Loads the current build again. */
78
79
  reload(): Promise<LoadedFilm>;
79
80
  seek(frame: number): void;
@@ -5,7 +5,7 @@ import { FrameCache } from "./frames.js";
5
5
  import { FilmNeeds } from "./needs.js";
6
6
  import { Cancelled, WorkerPool } from "./pool.js";
7
7
  import { bufferedAhead, clampFrame, startBuffer, wantedFrames } from "./schedule.js";
8
- import { openSource } from "./source.js";
8
+ import { openFilm } from "./source.js";
9
9
  import { Transport } from "./transport.js";
10
10
  /** Which workers use a file: audio the soundtrack's, proxy segments the video worker, a video's
11
11
  * facts both (frames lay out with them; the decoder reads its proxy from them), the rest frames. */
@@ -214,7 +214,7 @@ export function createPlayer(options) {
214
214
  async function load(source) {
215
215
  const mine = ++generation;
216
216
  lastSource = source;
217
- const opened = await openSource(source, typeof location !== "undefined" ? location.href : "http://localhost/");
217
+ const opened = await openFilm(source, typeof location !== "undefined" ? location.href : "http://localhost/");
218
218
  if (mine !== generation)
219
219
  throw new Cancelled();
220
220
  const doc = opened.doc;
@@ -1,14 +1,25 @@
1
1
  import { type BuildDoc, type FilmDoc } from "@tongue-core/project/doc";
2
2
  import type { AssetFileInfo, FetchBytes } from "./assets.js";
3
+ /** A build served as files: its folder's URL. */
3
4
  export interface BuildSource {
4
5
  /** The build's folder (where build.json is), absolute or relative to the page. */
5
6
  readonly build: string | URL;
6
7
  /** Another manifest in the build, such as one scene on its own. Default build.json. */
7
8
  readonly manifest?: string;
8
9
  }
10
+ /** A build read a file at a time, from wherever it is kept: a Git commit, memory, an archive. */
11
+ export interface FileSource {
12
+ /** One file of the build, by its path in the build as build.json and film.json give it:
13
+ * `build.json`, `film.json`, `scenes/title.5ebf1e9a05d5.js`, `assets/fonts/inter.ttf`.
14
+ * Rejects when the file can't be read. */
15
+ readonly read: (path: string, signal?: AbortSignal) => Promise<Uint8Array>;
16
+ /** Another manifest in the build, such as one scene on its own. Default build.json. */
17
+ readonly manifest?: string;
18
+ }
19
+ /** What a player loads: a build served as files, or one read a file at a time. */
20
+ export type FilmSource = BuildSource | FileSource;
9
21
  /** A build, opened: its documents, its text files for the workers, and its asset files. */
10
- export interface OpenedSource {
11
- readonly base: URL;
22
+ export interface OpenedBuild {
12
23
  readonly manifest: string;
13
24
  readonly build: BuildDoc;
14
25
  readonly doc: FilmDoc;
@@ -19,10 +30,18 @@ export interface OpenedSource {
19
30
  /** Reads one imported asset file by its path in the assets folder. */
20
31
  readonly fetchAsset: FetchBytes;
21
32
  }
33
+ /** A build served as files, opened: also the folder it is served from. */
34
+ export interface OpenedSource extends OpenedBuild {
35
+ readonly base: URL;
36
+ }
22
37
  export declare class SourceError extends Error {
23
38
  readonly name = "SourceError";
24
39
  }
25
40
  /** The folder URL of a build, ending in a slash so paths resolve inside it. */
26
41
  export declare function buildBase(build: string | URL, page: string): URL;
27
- /** Opens a build: its manifest and film.json, then every scene bundle at once. */
42
+ /** Opens a build served as files: every path resolved against its folder's URL. */
28
43
  export declare function openSource(source: BuildSource, page: string, signal?: AbortSignal): Promise<OpenedSource>;
44
+ /** Opens a build read a file at a time; an asset file is read at its path in the assets folder. */
45
+ export declare function openFiles(source: FileSource, signal?: AbortSignal): Promise<OpenedBuild>;
46
+ /** Opens either kind of source; a served build's folder is resolved against `page`. */
47
+ export declare function openFilm(source: FilmSource, page: string, signal?: AbortSignal): Promise<OpenedBuild>;
@@ -13,27 +13,55 @@ export function buildBase(build, page) {
13
13
  /** Fetches with revalidation: a server answers 304 for what hasn't changed, so a rebuild is seen at once. */
14
14
  async function fetchOk(url, signal) {
15
15
  const response = await fetch(url, { cache: "no-cache", signal }).catch((e) => {
16
- throw new SourceError(`${url.href} couldn't be fetched: ${e instanceof Error ? e.message : String(e)}`);
16
+ throw new SourceError(`${url.href} couldn't be fetched: ${message(e)}`);
17
17
  });
18
18
  if (!response.ok)
19
19
  throw new SourceError(`${url.href}: ${response.status} ${response.statusText}`);
20
20
  return response;
21
21
  }
22
- const failure = (e) => new SourceError(e instanceof Error ? e.message : String(e));
22
+ const message = (e) => (e instanceof Error ? e.message : String(e));
23
+ const failure = (e) => new SourceError(message(e));
23
24
  /** Opens a build: its manifest and film.json, then every scene bundle at once. */
24
- export async function openSource(source, page, signal) {
25
- const base = buildBase(source.build, page);
26
- const manifest = source.manifest ?? "build.json";
27
- const text = async (path) => (await fetchOk(new URL(path, base), signal)).text();
28
- const buildText = await text(manifest);
25
+ async function open(manifest, reader) {
26
+ const buildText = await reader.text(manifest);
29
27
  const build = await Effect.runPromise(parseBuildDoc(buildText)).catch((e) => { throw failure(e); });
30
- const filmText = await text(build.film);
28
+ const filmText = await reader.text(build.film);
31
29
  const doc = await Effect.runPromise(parseFilmDoc(filmText)).catch((e) => { throw failure(e); });
32
- const bundles = await Promise.all(doc.scenes.map(async (scene) => [scene.bundle, await text(scene.bundle)]));
30
+ const bundles = await Promise.all(doc.scenes.map(async (scene) => [scene.bundle, await reader.text(scene.bundle)]));
33
31
  const files = { [manifest]: buildText, [build.film]: filmText };
34
32
  for (const [path, code] of bundles)
35
33
  files[path] = code;
36
- const assetsBase = buildBase(build.assets.dir, base.href);
37
- const fetchAsset = async (path, abort) => new Uint8Array(await (await fetchOk(new URL(path, assetsBase), abort)).arrayBuffer());
38
- return { base, manifest, build, doc, files, assets: build.assets.files, fetchAsset };
34
+ return { manifest, build, doc, files, assets: build.assets.files, fetchAsset: reader.assets(build.assets.dir) };
35
+ }
36
+ /** Opens a build served as files: every path resolved against its folder's URL. */
37
+ export async function openSource(source, page, signal) {
38
+ const base = buildBase(source.build, page);
39
+ const opened = await open(source.manifest ?? "build.json", {
40
+ text: async (path) => (await fetchOk(new URL(path, base), signal)).text(),
41
+ assets: (dir) => {
42
+ const assetsBase = buildBase(dir, base.href);
43
+ return async (path, abort) => new Uint8Array(await (await fetchOk(new URL(path, assetsBase), abort)).arrayBuffer());
44
+ },
45
+ });
46
+ return { ...opened, base };
47
+ }
48
+ /** A file in one of the build's folders: "assets" and "fonts/inter.ttf" → "assets/fonts/inter.ttf". */
49
+ function inFolder(dir, path) {
50
+ const folder = dir.replace(/\/+$/, "");
51
+ return folder === "" || folder === "." ? path : `${folder}/${path}`;
52
+ }
53
+ /** Opens a build read a file at a time; an asset file is read at its path in the assets folder. */
54
+ export async function openFiles(source, signal) {
55
+ const bytes = (path, abort) => source.read(path, abort).catch((e) => {
56
+ throw new SourceError(`${path} couldn't be read: ${message(e)}`);
57
+ });
58
+ const utf8 = new TextDecoder();
59
+ return open(source.manifest ?? "build.json", {
60
+ text: async (path) => utf8.decode(await bytes(path, signal)),
61
+ assets: (dir) => (path, abort) => bytes(inFolder(dir, path), abort),
62
+ });
63
+ }
64
+ /** Opens either kind of source; a served build's folder is resolved against `page`. */
65
+ export function openFilm(source, page, signal) {
66
+ return "read" in source ? openFiles(source, signal) : openSource(source, page, signal);
39
67
  }
@@ -6,4 +6,5 @@ export { ruler, stepFor, minorFor, rulerLabel, timecode, LABEL_SPACING, MINOR_SP
6
6
  export { reduceSelection, emptySelection, type Selection, type SelectionAction } from "./selection.js";
7
7
  export { rowItems, rowTransitions, clipInRun, octaveOf, sameRowItems, MIN_CLIP_PX, type RowItem } from "./items.js";
8
8
  export { filmstrip, PreviewCache, type PreviewSource, type StripFrame } from "./previews.js";
9
+ export { narration, wordAt, wordsIn, lastStartedBy, type NarrationWord } from "./narration.js";
9
10
  export { BUDGETS, type Budgets } from "../budgets.js";
@@ -10,4 +10,5 @@ export { ruler, stepFor, minorFor, rulerLabel, timecode, LABEL_SPACING, MINOR_SP
10
10
  export { reduceSelection, emptySelection } from "./selection.js";
11
11
  export { rowItems, rowTransitions, clipInRun, octaveOf, sameRowItems, MIN_CLIP_PX } from "./items.js";
12
12
  export { filmstrip, PreviewCache } from "./previews.js";
13
+ export { narration, wordAt, wordsIn, lastStartedBy } from "./narration.js";
13
14
  export { BUDGETS } from "../budgets.js";
@@ -0,0 +1,18 @@
1
+ import type { FilmDoc } from "@tongue-core/project/doc";
2
+ export interface NarrationWord {
3
+ readonly text: string;
4
+ /** Film frames [start, end). */
5
+ readonly start: number;
6
+ readonly end: number;
7
+ /** The clip it is said in, and its row. */
8
+ readonly clip: string;
9
+ readonly row: string;
10
+ }
11
+ /** Every word of the film's narration on the film's clock, by start. Empty for a film with none. */
12
+ export declare function narration(doc: FilmDoc): ReadonlyArray<NarrationWord>;
13
+ /** The index of the last word that has started by `frame`; -1 before the first. */
14
+ export declare function lastStartedBy(words: ReadonlyArray<NarrationWord>, frame: number): number;
15
+ /** The word being said at `frame` (its start reached, its end not), or null between words. */
16
+ export declare function wordAt(words: ReadonlyArray<NarrationWord>, frame: number): NarrationWord | null;
17
+ /** The words said in `[from, to)`, in order. */
18
+ export declare const wordsIn: (words: ReadonlyArray<NarrationWord>, from: number, to: number) => ReadonlyArray<NarrationWord>;
@@ -0,0 +1,42 @@
1
+ /** Every word of the film's narration on the film's clock, by start. Empty for a film with none. */
2
+ export function narration(doc) {
3
+ const out = [];
4
+ for (const track of doc.tracks) {
5
+ for (const clip of track.clips) {
6
+ if (clip.kind !== "audio" || clip.voice === undefined)
7
+ continue;
8
+ for (const word of clip.voice.words) {
9
+ const start = clip.start + word.start / clip.speed;
10
+ const end = clip.start + word.end / clip.speed;
11
+ if (start >= clip.end)
12
+ continue;
13
+ out.push({ text: word.text, start, end: Math.min(end, clip.end), clip: clip.id, row: track.id });
14
+ }
15
+ }
16
+ }
17
+ return out.sort((a, b) => a.start - b.start || a.end - b.end);
18
+ }
19
+ /** The index of the last word that has started by `frame`; -1 before the first. */
20
+ export function lastStartedBy(words, frame) {
21
+ let low = 0;
22
+ let high = words.length - 1;
23
+ let found = -1;
24
+ while (low <= high) {
25
+ const mid = (low + high) >>> 1;
26
+ if ((words[mid]?.start ?? Number.POSITIVE_INFINITY) <= frame) {
27
+ found = mid;
28
+ low = mid + 1;
29
+ }
30
+ else
31
+ high = mid - 1;
32
+ }
33
+ return found;
34
+ }
35
+ /** The word being said at `frame` (its start reached, its end not), or null between words. */
36
+ export function wordAt(words, frame) {
37
+ const index = lastStartedBy(words, frame);
38
+ const word = words[index];
39
+ return word !== undefined && frame < word.end ? word : null;
40
+ }
41
+ /** The words said in `[from, to)`, in order. */
42
+ export const wordsIn = (words, from, to) => words.filter((word) => word.end > from && word.start < to);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tongue-core/ui",
3
- "version": "0.1.0-rc.3",
3
+ "version": "0.1.0-rc.5",
4
4
  "description": "The pieces of a film's interface: the player, the timeline's state and geometry, and unstyled React primitives. Build your own studio from them.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,9 +36,9 @@
36
36
  "README.md"
37
37
  ],
38
38
  "dependencies": {
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"
39
+ "@tongue-core/engine": "0.1.0-rc.5",
40
+ "@tongue-core/film": "0.1.0-rc.5",
41
+ "@tongue-core/project": "0.1.0-rc.5"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@effect/atom-react": "^4.0.6",