zephyra-codec 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -0
  3. package/dist/container/mp4Box.d.ts +46 -0
  4. package/dist/container/mp4Box.d.ts.map +1 -0
  5. package/dist/container/mp4Box.js +115 -0
  6. package/dist/container/mp4Box.js.map +1 -0
  7. package/dist/deconstructor.d.ts +15 -0
  8. package/dist/deconstructor.d.ts.map +1 -0
  9. package/dist/deconstructor.js +40 -0
  10. package/dist/deconstructor.js.map +1 -0
  11. package/dist/exporter.d.ts +11 -0
  12. package/dist/exporter.d.ts.map +1 -0
  13. package/dist/exporter.js +51 -0
  14. package/dist/exporter.js.map +1 -0
  15. package/dist/ffmpeg/ffmpegBinary.d.ts +16 -0
  16. package/dist/ffmpeg/ffmpegBinary.d.ts.map +1 -0
  17. package/dist/ffmpeg/ffmpegBinary.js +32 -0
  18. package/dist/ffmpeg/ffmpegBinary.js.map +1 -0
  19. package/dist/ffmpeg/render.d.ts +22 -0
  20. package/dist/ffmpeg/render.d.ts.map +1 -0
  21. package/dist/ffmpeg/render.js +88 -0
  22. package/dist/ffmpeg/render.js.map +1 -0
  23. package/dist/format.d.ts +3 -0
  24. package/dist/format.d.ts.map +1 -0
  25. package/dist/format.js +3 -0
  26. package/dist/format.js.map +1 -0
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +11 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/serialization/msgpack.d.ts +26 -0
  32. package/dist/serialization/msgpack.d.ts.map +1 -0
  33. package/dist/serialization/msgpack.js +334 -0
  34. package/dist/serialization/msgpack.js.map +1 -0
  35. package/dist/serialization/payload.d.ts +16 -0
  36. package/dist/serialization/payload.d.ts.map +1 -0
  37. package/dist/serialization/payload.js +80 -0
  38. package/dist/serialization/payload.js.map +1 -0
  39. package/dist/types.d.ts +97 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +16 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist/utils/hash.d.ts +2 -0
  44. package/dist/utils/hash.d.ts.map +1 -0
  45. package/dist/utils/hash.js +5 -0
  46. package/dist/utils/hash.js.map +1 -0
  47. package/package.json +47 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BlackBlazent
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # @zephyra/blackvideo-codec SDK
2
+
3
+ Standalone Node.js/TypeScript library implementing Zephyra's Exporter and
4
+ Deconstructor. No UI, no host assumptions — this package only knows how to
5
+ turn a base video + layer instructions into a normal, playable video file
6
+ with the original source embedded inside it, and how to read that back out
7
+ byte-for-byte.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install zephyra-codec
13
+ pnpm install zephyra-codec
14
+ ```
15
+
16
+ Requires `ffmpeg` on `PATH` (or pass `ffmpegPath` explicitly). Node.js LTS
17
+ (>=18).
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { Exporter, Deconstructor } from "zephyra-codec";
23
+
24
+ const exporter = new Exporter();
25
+ const result = await exporter.export({
26
+ baseVideoPath: "./raw/interview.mp4",
27
+ layers: [
28
+ {
29
+ id: "lower-third",
30
+ type: "text",
31
+ startTime: 2,
32
+ endTime: 8,
33
+ transform: { x: 40, y: 400, scale: 1, rotation: 0 },
34
+ opacity: 1,
35
+ data: { text: "Jane Doe, CTO", fontsize: 28, fontcolor: "white" },
36
+ },
37
+ ],
38
+ outputPath: "./out/interview.zephyra.mp4",
39
+ });
40
+ // result.outputPath is a completely normal, playable mp4.
41
+
42
+ const deconstructor = new Deconstructor();
43
+ const restored = await deconstructor.deconstruct({
44
+ inputPath: "./out/interview.zephyra.mp4",
45
+ outDir: "./restored",
46
+ });
47
+ // restored.baseVideoPath is byte-identical to ./raw/interview.mp4
48
+ // restored.timeline.layers is the original layers array
49
+ ```
50
+
51
+ ## How it works
52
+
53
+ 1. **Exporter** renders a standard flattened video with ffmpeg, then
54
+ appends a single unknown-typed top-level box (`zeph`) to the end of the
55
+ ISO-BMFF (mp4) container. Standard players and demuxers iterate the box
56
+ list and skip types they don't recognize — this is the same mechanism
57
+ vendor metadata boxes use today, not a hack specific to this SDK.
58
+ Appending at the end never shifts the byte offsets of anything already
59
+ written, so nothing inside `moov` needs to be rewritten.
60
+ 2. The `zeph` box payload is the original base video's raw bytes plus the
61
+ layer instructions, MessagePack-encoded and gzip-compressed.
62
+ 3. **Deconstructor** never touches ffmpeg. It scans the box list, finds
63
+ `zeph`, decompresses and decodes it, and writes the base video bytes
64
+ back out untouched. This is why restoration is byte-exact — the
65
+ original bytes were never re-encoded, only carried along for the ride.
66
+
67
+ ## Design notes / deliberate scope decisions
68
+
69
+ - **Zero runtime dependencies.** The MessagePack codec
70
+ (`src/serialization/msgpack.ts`) and the ffmpeg process wrapper
71
+ (`src/ffmpeg/ffmpegBinary.ts`) are hand-rolled rather than pulled in via
72
+ `msgpackr`/`fluent-ffmpeg`. Both are wire/behavior compatible with their
73
+ namesake libraries (MessagePack is a standard spec; the ffmpeg wrapper
74
+ is just `spawn` + arg building), so a host is free to swap them in
75
+ later — but keeping the dependency tree empty removes the biggest
76
+ source of friction when this package is eventually bundled into a
77
+ `pkg`/`nexe` sidecar binary.
78
+ - **Core vs. I/O separation.** `Exporter`/`Deconstructor` are the only
79
+ filesystem-touching pieces. Everything they call —
80
+ `encodePayload`/`decodePayload`, `appendBox`/`findTopLevelBox` — is a
81
+ pure function operating on `Buffer`s in memory. These are re-exported
82
+ from `src/index.ts` specifically so a future HTTP/WebSocket wrapper can
83
+ call them directly against request bodies without going through disk.
84
+ - **No AI, no approximation in Deconstructor.** Restoration is pure
85
+ ISO-BMFF box scanning + decompression. The round-trip test
86
+ (`test/roundtrip.test.ts`) is the acceptance bar: export → deconstruct →
87
+ `Buffer.compare` against the original source must be `0`, every time.
88
+
89
+ ## Scripts
90
+
91
+ ```bash
92
+ npm run build # tsc -> dist/
93
+ npm test # runs the full test suite (node:test via tsx), including the round-trip test
94
+ npm run typecheck # tsc --noEmit
95
+ ```
96
+
97
+ ## Public API
98
+
99
+ - `Exporter.export(options: ExportOptions): Promise<ExportResult>`
100
+ - `Deconstructor.deconstruct(options: DeconstructOptions): Promise<DeconstructResult>`
101
+ - Pure helpers: `encodePayload`, `decodePayload`, `appendBox`,
102
+ `findTopLevelBox`, `readBoxPayload`, `listTopLevelBoxes`,
103
+ `stripTopLevelBox`, `msgpackEncode`, `msgpackDecode`
104
+ - Types: `LayerInstruction`, `ZephyraTimeline`, `ZephyraPayload`,
105
+ `ExportOptions`, `ExportResult`, `DeconstructOptions`,
106
+ `DeconstructResult`, `ZephyraFormatError`
107
+
108
+ See `src/types.ts` for full type definitions.
109
+
110
+ ## Out of scope (by design, per the project brief)
111
+
112
+ No UI, no Tauri, no desktop shell — this package assumes nothing about
113
+ who calls it. It's meant to be imported directly by a Node host, compiled
114
+ into a sidecar binary, or wrapped in an HTTP/WebSocket server, none of
115
+ which are implemented here.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Minimal ISO Base Media File Format (mp4/mov/m4v) top-level box reader and
3
+ * writer.
4
+ *
5
+ * Why this works for hiding a payload in plain sight: an ISO-BMFF file is
6
+ * just a flat sequence of boxes ("atoms"), each with an 8-byte header
7
+ * (4-byte size, 4-byte fourCC type) followed by its payload. Conformant
8
+ * players and demuxers iterate that box list top-to-bottom and skip any
9
+ * box whose type they don't recognize — this is how real-world tools embed
10
+ * private/vendor data today (e.g. iTunes metadata boxes, Adobe XMP boxes).
11
+ * Appending a new, unknown-typed box after the existing boxes:
12
+ * - never shifts the byte offsets of anything already written (all
13
+ * internal offset tables inside `moov`, e.g. `stco`/`co64`, stay valid)
14
+ * - is trivially skippable by any standards-compliant player
15
+ * - is trivially locatable by us, since we already know the type to
16
+ * look for
17
+ *
18
+ * All functions here operate on Buffers only — no filesystem access — so
19
+ * they can be reused verbatim by an HTTP handler operating on request
20
+ * bodies, or bundled into a compiled sidecar.
21
+ */
22
+ export interface BoxInfo {
23
+ type: string;
24
+ /** Offset of the box's header (not its payload) within the file. */
25
+ offset: number;
26
+ /** Total size of the box, header included. */
27
+ totalSize: number;
28
+ /** Offset where this box's payload begins. */
29
+ payloadOffset: number;
30
+ /** Size of this box's payload, header excluded. */
31
+ payloadSize: number;
32
+ }
33
+ /** Walk every top-level box in a buffer, in order. Tolerant of the size==0 "rest of file" convention. */
34
+ export declare function listTopLevelBoxes(buf: Buffer): BoxInfo[];
35
+ /** Find the first top-level box of the given type, or null. */
36
+ export declare function findTopLevelBox(buf: Buffer, type: string): BoxInfo | null;
37
+ /** Extract a box's payload bytes. */
38
+ export declare function readBoxPayload(buf: Buffer, box: BoxInfo): Buffer;
39
+ /**
40
+ * Append a new top-level box of `type` containing `payload` to the end of
41
+ * `fileBuf`. Returns a new Buffer; does not mutate the input.
42
+ */
43
+ export declare function appendBox(fileBuf: Buffer, type: string, payload: Buffer): Buffer;
44
+ /** Strip a top-level box (by type) from a buffer, returning the remainder. Used mainly by tests. */
45
+ export declare function stripTopLevelBox(fileBuf: Buffer, type: string): Buffer;
46
+ //# sourceMappingURL=mp4Box.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mp4Box.d.ts","sourceRoot":"","sources":["../../src/container/mp4Box.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAKH,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC;IACf,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,aAAa,EAAE,MAAM,CAAC;IACtB,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAC;CACrB;AASD,yGAAyG;AACzG,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,EAAE,CA2CxD;AAED,+DAA+D;AAC/D,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CAKzE;AAED,qCAAqC;AACrC,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,GAAG,MAAM,CAEhE;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAiBhF;AAED,oGAAoG;AACpG,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAOtE"}
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Minimal ISO Base Media File Format (mp4/mov/m4v) top-level box reader and
3
+ * writer.
4
+ *
5
+ * Why this works for hiding a payload in plain sight: an ISO-BMFF file is
6
+ * just a flat sequence of boxes ("atoms"), each with an 8-byte header
7
+ * (4-byte size, 4-byte fourCC type) followed by its payload. Conformant
8
+ * players and demuxers iterate that box list top-to-bottom and skip any
9
+ * box whose type they don't recognize — this is how real-world tools embed
10
+ * private/vendor data today (e.g. iTunes metadata boxes, Adobe XMP boxes).
11
+ * Appending a new, unknown-typed box after the existing boxes:
12
+ * - never shifts the byte offsets of anything already written (all
13
+ * internal offset tables inside `moov`, e.g. `stco`/`co64`, stay valid)
14
+ * - is trivially skippable by any standards-compliant player
15
+ * - is trivially locatable by us, since we already know the type to
16
+ * look for
17
+ *
18
+ * All functions here operate on Buffers only — no filesystem access — so
19
+ * they can be reused verbatim by an HTTP handler operating on request
20
+ * bodies, or bundled into a compiled sidecar.
21
+ */
22
+ const HEADER_SIZE = 8; // 4-byte size + 4-byte type
23
+ const LARGE_SIZE_EXTRA = 8; // extra 8 bytes when size field == 1
24
+ function fourCC(type) {
25
+ if (!/^[\x20-\x7e]{4}$/.test(type)) {
26
+ throw new Error(`Invalid box type "${type}": must be exactly 4 ASCII chars`);
27
+ }
28
+ return Buffer.from(type, "ascii");
29
+ }
30
+ /** Walk every top-level box in a buffer, in order. Tolerant of the size==0 "rest of file" convention. */
31
+ export function listTopLevelBoxes(buf) {
32
+ const boxes = [];
33
+ let offset = 0;
34
+ while (offset + HEADER_SIZE <= buf.length) {
35
+ const declaredSize = buf.readUInt32BE(offset);
36
+ const type = buf.toString("ascii", offset + 4, offset + 8);
37
+ let headerSize = HEADER_SIZE;
38
+ let totalSize;
39
+ if (declaredSize === 1) {
40
+ // 64-bit extended size follows the type field.
41
+ if (offset + HEADER_SIZE + LARGE_SIZE_EXTRA > buf.length)
42
+ break;
43
+ const big = buf.readBigUInt64BE(offset + HEADER_SIZE);
44
+ totalSize = Number(big);
45
+ headerSize = HEADER_SIZE + LARGE_SIZE_EXTRA;
46
+ }
47
+ else if (declaredSize === 0) {
48
+ // Box extends to the end of the file (only legal as the last box).
49
+ totalSize = buf.length - offset;
50
+ }
51
+ else {
52
+ totalSize = declaredSize;
53
+ }
54
+ if (totalSize < headerSize || offset + totalSize > buf.length) {
55
+ // Malformed / truncated — stop scanning rather than throw, so callers
56
+ // can decide whether a missing payload box is fatal.
57
+ break;
58
+ }
59
+ boxes.push({
60
+ type,
61
+ offset,
62
+ totalSize,
63
+ payloadOffset: offset + headerSize,
64
+ payloadSize: totalSize - headerSize,
65
+ });
66
+ if (declaredSize === 0)
67
+ break; // that box consumed the rest of the file
68
+ offset += totalSize;
69
+ }
70
+ return boxes;
71
+ }
72
+ /** Find the first top-level box of the given type, or null. */
73
+ export function findTopLevelBox(buf, type) {
74
+ for (const box of listTopLevelBoxes(buf)) {
75
+ if (box.type === type)
76
+ return box;
77
+ }
78
+ return null;
79
+ }
80
+ /** Extract a box's payload bytes. */
81
+ export function readBoxPayload(buf, box) {
82
+ return buf.subarray(box.payloadOffset, box.payloadOffset + box.payloadSize);
83
+ }
84
+ /**
85
+ * Append a new top-level box of `type` containing `payload` to the end of
86
+ * `fileBuf`. Returns a new Buffer; does not mutate the input.
87
+ */
88
+ export function appendBox(fileBuf, type, payload) {
89
+ const typeBuf = fourCC(type);
90
+ const use64 = payload.length + HEADER_SIZE > 0xffffffff;
91
+ let header;
92
+ if (use64) {
93
+ header = Buffer.alloc(HEADER_SIZE + LARGE_SIZE_EXTRA);
94
+ header.writeUInt32BE(1, 0); // signal extended size
95
+ typeBuf.copy(header, 4);
96
+ header.writeBigUInt64BE(BigInt(payload.length + HEADER_SIZE + LARGE_SIZE_EXTRA), 8);
97
+ }
98
+ else {
99
+ header = Buffer.alloc(HEADER_SIZE);
100
+ header.writeUInt32BE(payload.length + HEADER_SIZE, 0);
101
+ typeBuf.copy(header, 4);
102
+ }
103
+ return Buffer.concat([fileBuf, header, payload]);
104
+ }
105
+ /** Strip a top-level box (by type) from a buffer, returning the remainder. Used mainly by tests. */
106
+ export function stripTopLevelBox(fileBuf, type) {
107
+ const box = findTopLevelBox(fileBuf, type);
108
+ if (!box)
109
+ return Buffer.from(fileBuf);
110
+ return Buffer.concat([
111
+ fileBuf.subarray(0, box.offset),
112
+ fileBuf.subarray(box.offset + box.totalSize),
113
+ ]);
114
+ }
115
+ //# sourceMappingURL=mp4Box.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mp4Box.js","sourceRoot":"","sources":["../../src/container/mp4Box.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,WAAW,GAAG,CAAC,CAAC,CAAC,4BAA4B;AACnD,MAAM,gBAAgB,GAAG,CAAC,CAAC,CAAC,qCAAqC;AAcjE,SAAS,MAAM,CAAC,IAAY;IAC1B,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACnC,MAAM,IAAI,KAAK,CAAC,qBAAqB,IAAI,kCAAkC,CAAC,CAAC;IAC/E,CAAC;IACD,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;AACpC,CAAC;AAED,yGAAyG;AACzG,MAAM,UAAU,iBAAiB,CAAC,GAAW;IAC3C,MAAM,KAAK,GAAc,EAAE,CAAC;IAC5B,IAAI,MAAM,GAAG,CAAC,CAAC;IAEf,OAAO,MAAM,GAAG,WAAW,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC;QAC1C,MAAM,YAAY,GAAG,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;QAC9C,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,CAAC;QAE3D,IAAI,UAAU,GAAG,WAAW,CAAC;QAC7B,IAAI,SAAiB,CAAC;QAEtB,IAAI,YAAY,KAAK,CAAC,EAAE,CAAC;YACvB,+CAA+C;YAC/C,IAAI,MAAM,GAAG,WAAW,GAAG,gBAAgB,GAAG,GAAG,CAAC,MAAM;gBAAE,MAAM;YAChE,MAAM,GAAG,GAAG,GAAG,CAAC,eAAe,CAAC,MAAM,GAAG,WAAW,CAAC,CAAC;YACtD,SAAS,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;YACxB,UAAU,GAAG,WAAW,GAAG,gBAAgB,CAAC;QAC9C,CAAC;aAAM,IAAI,YAAY,KAAK,CAAC,EAAE,CAAC;YAC9B,mEAAmE;YACnE,SAAS,GAAG,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC;QAClC,CAAC;aAAM,CAAC;YACN,SAAS,GAAG,YAAY,CAAC;QAC3B,CAAC;QAED,IAAI,SAAS,GAAG,UAAU,IAAI,MAAM,GAAG,SAAS,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC;YAC9D,sEAAsE;YACtE,qDAAqD;YACrD,MAAM;QACR,CAAC;QAED,KAAK,CAAC,IAAI,CAAC;YACT,IAAI;YACJ,MAAM;YACN,SAAS;YACT,aAAa,EAAE,MAAM,GAAG,UAAU;YAClC,WAAW,EAAE,SAAS,GAAG,UAAU;SACpC,CAAC,CAAC;QAEH,IAAI,YAAY,KAAK,CAAC;YAAE,MAAM,CAAC,yCAAyC;QACxE,MAAM,IAAI,SAAS,CAAC;IACtB,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC;AAED,+DAA+D;AAC/D,MAAM,UAAU,eAAe,CAAC,GAAW,EAAE,IAAY;IACvD,KAAK,MAAM,GAAG,IAAI,iBAAiB,CAAC,GAAG,CAAC,EAAE,CAAC;QACzC,IAAI,GAAG,CAAC,IAAI,KAAK,IAAI;YAAE,OAAO,GAAG,CAAC;IACpC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,qCAAqC;AACrC,MAAM,UAAU,cAAc,CAAC,GAAW,EAAE,GAAY;IACtD,OAAO,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,aAAa,EAAE,GAAG,CAAC,aAAa,GAAG,GAAG,CAAC,WAAW,CAAC,CAAC;AAC9E,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,OAAe,EAAE,IAAY,EAAE,OAAe;IACtE,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC;IAC7B,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,GAAG,WAAW,GAAG,UAAU,CAAC;IAExD,IAAI,MAAc,CAAC;IACnB,IAAI,KAAK,EAAE,CAAC;QACV,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,WAAW,GAAG,gBAAgB,CAAC,CAAC;QACtD,MAAM,CAAC,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,uBAAuB;QACnD,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACxB,MAAM,CAAC,gBAAgB,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,GAAG,WAAW,GAAG,gBAAgB,CAAC,EAAE,CAAC,CAAC,CAAC;IACtF,CAAC;SAAM,CAAC;QACN,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;QACnC,MAAM,CAAC,aAAa,CAAC,OAAO,CAAC,MAAM,GAAG,WAAW,EAAE,CAAC,CAAC,CAAC;QACtD,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;IAC1B,CAAC;IAED,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AACnD,CAAC;AAED,oGAAoG;AACpG,MAAM,UAAU,gBAAgB,CAAC,OAAe,EAAE,IAAY;IAC5D,MAAM,GAAG,GAAG,eAAe,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;IAC3C,IAAI,CAAC,GAAG;QAAE,OAAO,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACtC,OAAO,MAAM,CAAC,MAAM,CAAC;QACnB,OAAO,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC;QAC/B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,SAAS,CAAC;KAC7C,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,15 @@
1
+ import type { DeconstructOptions, DeconstructResult } from "./types.js";
2
+ /**
3
+ * Restores the original base video and layer instructions from a file
4
+ * produced by Exporter.export(). This is deliberately ffmpeg-free: it
5
+ * never decodes or re-encodes a single frame. Restoration is a matter of
6
+ * finding the "zeph" box by scanning the container's box list and reading
7
+ * the bytes back out — deterministic file-structure parsing, not
8
+ * inference. The same guarantee holds no matter how the flattened video
9
+ * track was encoded, because the embedded payload is untouched from
10
+ * export time.
11
+ */
12
+ export declare class Deconstructor {
13
+ deconstruct(options: DeconstructOptions): Promise<DeconstructResult>;
14
+ }
15
+ //# sourceMappingURL=deconstructor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deconstructor.d.ts","sourceRoot":"","sources":["../src/deconstructor.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAExE;;;;;;;;;GASG;AACH,qBAAa,aAAa;IAClB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC;CA0B3E"}
@@ -0,0 +1,40 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { findTopLevelBox, readBoxPayload } from "./container/mp4Box.js";
4
+ import { decodePayload } from "./serialization/payload.js";
5
+ import { sha256Hex } from "./utils/hash.js";
6
+ import { ZEPHYRA_BOX_TYPE } from "./format.js";
7
+ import { ZephyraFormatError } from "./types.js";
8
+ /**
9
+ * Restores the original base video and layer instructions from a file
10
+ * produced by Exporter.export(). This is deliberately ffmpeg-free: it
11
+ * never decodes or re-encodes a single frame. Restoration is a matter of
12
+ * finding the "zeph" box by scanning the container's box list and reading
13
+ * the bytes back out — deterministic file-structure parsing, not
14
+ * inference. The same guarantee holds no matter how the flattened video
15
+ * track was encoded, because the embedded payload is untouched from
16
+ * export time.
17
+ */
18
+ export class Deconstructor {
19
+ async deconstruct(options) {
20
+ const fileBytes = await readFile(options.inputPath);
21
+ const box = findTopLevelBox(fileBytes, ZEPHYRA_BOX_TYPE);
22
+ if (!box) {
23
+ throw new ZephyraFormatError(`No Zephyra payload found in "${options.inputPath}". ` +
24
+ "This file was not exported by the Zephyra SDK, or the payload box has been stripped.");
25
+ }
26
+ const compressedPayload = readBoxPayload(fileBytes, box);
27
+ const payload = decodePayload(compressedPayload);
28
+ await mkdir(options.outDir, { recursive: true });
29
+ const baseVideoPath = join(options.outDir, payload.timeline.baseVideoFilename);
30
+ await writeFile(baseVideoPath, payload.baseVideoBytes);
31
+ return {
32
+ baseVideoPath,
33
+ timeline: payload.timeline,
34
+ baseVideoChecksum: sha256Hex(payload.baseVideoBytes),
35
+ version: payload.version,
36
+ createdAt: payload.createdAt,
37
+ };
38
+ }
39
+ }
40
+ //# sourceMappingURL=deconstructor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deconstructor.js","sourceRoot":"","sources":["../src/deconstructor.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAC9D,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACxE,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAGhD;;;;;;;;;GASG;AACH,MAAM,OAAO,aAAa;IACxB,KAAK,CAAC,WAAW,CAAC,OAA2B;QAC3C,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;QAEpD,MAAM,GAAG,GAAG,eAAe,CAAC,SAAS,EAAE,gBAAgB,CAAC,CAAC;QACzD,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,MAAM,IAAI,kBAAkB,CAC1B,gCAAgC,OAAO,CAAC,SAAS,KAAK;gBACpD,sFAAsF,CACzF,CAAC;QACJ,CAAC;QAED,MAAM,iBAAiB,GAAG,cAAc,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;QACzD,MAAM,OAAO,GAAG,aAAa,CAAC,iBAAiB,CAAC,CAAC;QAEjD,MAAM,KAAK,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACjD,MAAM,aAAa,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAC,CAAC;QAC/E,MAAM,SAAS,CAAC,aAAa,EAAE,OAAO,CAAC,cAAc,CAAC,CAAC;QAEvD,OAAO;YACL,aAAa;YACb,QAAQ,EAAE,OAAO,CAAC,QAAQ;YAC1B,iBAAiB,EAAE,SAAS,CAAC,OAAO,CAAC,cAAc,CAAC;YACpD,OAAO,EAAE,OAAO,CAAC,OAAO;YACxB,SAAS,EAAE,OAAO,CAAC,SAAS;SAC7B,CAAC;IACJ,CAAC;CACF"}
@@ -0,0 +1,11 @@
1
+ import type { ExportOptions, ExportResult } from "./types.js";
2
+ /**
3
+ * The only Node-specific (I/O-bound) piece of the export path. Everything
4
+ * it delegates to — payload encoding, box appending — operates on Buffers
5
+ * and would work unchanged if this class were replaced by an HTTP handler
6
+ * that reads an upload into memory instead of from disk.
7
+ */
8
+ export declare class Exporter {
9
+ export(options: ExportOptions): Promise<ExportResult>;
10
+ }
11
+ //# sourceMappingURL=exporter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exporter.d.ts","sourceRoot":"","sources":["../src/exporter.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAmB,MAAM,YAAY,CAAC;AAE/E;;;;;GAKG;AACH,qBAAa,QAAQ;IACb,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC;CAuC5D"}
@@ -0,0 +1,51 @@
1
+ import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join, basename } from "node:path";
4
+ import { renderFlattenedVideo } from "./ffmpeg/render.js";
5
+ import { encodePayload } from "./serialization/payload.js";
6
+ import { appendBox } from "./container/mp4Box.js";
7
+ import { sha256Hex } from "./utils/hash.js";
8
+ import { ZEPHYRA_BOX_TYPE } from "./format.js";
9
+ /**
10
+ * The only Node-specific (I/O-bound) piece of the export path. Everything
11
+ * it delegates to — payload encoding, box appending — operates on Buffers
12
+ * and would work unchanged if this class were replaced by an HTTP handler
13
+ * that reads an upload into memory instead of from disk.
14
+ */
15
+ export class Exporter {
16
+ async export(options) {
17
+ const baseVideoBytes = await readFile(options.baseVideoPath);
18
+ const baseVideoChecksum = sha256Hex(baseVideoBytes);
19
+ const timeline = {
20
+ baseVideoFilename: basename(options.baseVideoPath),
21
+ fps: options.fps,
22
+ layers: options.layers,
23
+ };
24
+ // Render the standard, fully playable flattened video into a scratch
25
+ // location first, then append the payload box to it in place.
26
+ const scratchDir = await mkdtemp(join(tmpdir(), "zephyra-export-"));
27
+ const renderedPath = join(scratchDir, "flattened.mp4");
28
+ try {
29
+ await renderFlattenedVideo({
30
+ baseVideoPath: options.baseVideoPath,
31
+ layers: options.layers,
32
+ outputPath: renderedPath,
33
+ ffmpegPath: options.ffmpegPath,
34
+ });
35
+ const renderedBytes = await readFile(renderedPath);
36
+ const payload = encodePayload(timeline, baseVideoBytes);
37
+ const finalBytes = appendBox(renderedBytes, ZEPHYRA_BOX_TYPE, payload);
38
+ await writeFile(options.outputPath, finalBytes);
39
+ return {
40
+ outputPath: options.outputPath,
41
+ fileSizeBytes: finalBytes.length,
42
+ payloadSizeBytes: payload.length,
43
+ baseVideoChecksum,
44
+ };
45
+ }
46
+ finally {
47
+ await rm(scratchDir, { recursive: true, force: true });
48
+ }
49
+ }
50
+ }
51
+ //# sourceMappingURL=exporter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"exporter.js","sourceRoot":"","sources":["../src/exporter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAClD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAG/C;;;;;GAKG;AACH,MAAM,OAAO,QAAQ;IACnB,KAAK,CAAC,MAAM,CAAC,OAAsB;QACjC,MAAM,cAAc,GAAG,MAAM,QAAQ,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC;QAC7D,MAAM,iBAAiB,GAAG,SAAS,CAAC,cAAc,CAAC,CAAC;QAEpD,MAAM,QAAQ,GAAoB;YAChC,iBAAiB,EAAE,QAAQ,CAAC,OAAO,CAAC,aAAa,CAAC;YAClD,GAAG,EAAE,OAAO,CAAC,GAAG;YAChB,MAAM,EAAE,OAAO,CAAC,MAAM;SACvB,CAAC;QAEF,qEAAqE;QACrE,8DAA8D;QAC9D,MAAM,UAAU,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,iBAAiB,CAAC,CAAC,CAAC;QACpE,MAAM,YAAY,GAAG,IAAI,CAAC,UAAU,EAAE,eAAe,CAAC,CAAC;QAEvD,IAAI,CAAC;YACH,MAAM,oBAAoB,CAAC;gBACzB,aAAa,EAAE,OAAO,CAAC,aAAa;gBACpC,MAAM,EAAE,OAAO,CAAC,MAAM;gBACtB,UAAU,EAAE,YAAY;gBACxB,UAAU,EAAE,OAAO,CAAC,UAAU;aAC/B,CAAC,CAAC;YAEH,MAAM,aAAa,GAAG,MAAM,QAAQ,CAAC,YAAY,CAAC,CAAC;YACnD,MAAM,OAAO,GAAG,aAAa,CAAC,QAAQ,EAAE,cAAc,CAAC,CAAC;YACxD,MAAM,UAAU,GAAG,SAAS,CAAC,aAAa,EAAE,gBAAgB,EAAE,OAAO,CAAC,CAAC;YAEvE,MAAM,SAAS,CAAC,OAAO,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;YAEhD,OAAO;gBACL,UAAU,EAAE,OAAO,CAAC,UAAU;gBAC9B,aAAa,EAAE,UAAU,CAAC,MAAM;gBAChC,gBAAgB,EAAE,OAAO,CAAC,MAAM;gBAChC,iBAAiB;aAClB,CAAC;QACJ,CAAC;gBAAS,CAAC;YACT,MAAM,EAAE,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QACzD,CAAC;IACH,CAAC;CACF"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Deliberately not using fluent-ffmpeg: it's a thin wrapper over exactly
3
+ * this (spawn + arg building), and cutting it removes a dependency that
4
+ * has historically been finicky to bundle into pkg/nexe binaries. If a
5
+ * host application would rather use fluent-ffmpeg for its richer filter
6
+ * DSL, it can — this function's signature (args in, stdout/stderr out) is
7
+ * intentionally the same shape fluent-ffmpeg or any other wrapper would
8
+ * need, so swapping it later doesn't touch render.ts's call sites.
9
+ */
10
+ export declare function runFfmpeg(args: string[], options?: {
11
+ ffmpegPath?: string;
12
+ }): Promise<{
13
+ stdout: string;
14
+ stderr: string;
15
+ }>;
16
+ //# sourceMappingURL=ffmpegBinary.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ffmpegBinary.d.ts","sourceRoot":"","sources":["../../src/ffmpeg/ffmpegBinary.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GACpC,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CA0B7C"}
@@ -0,0 +1,32 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Deliberately not using fluent-ffmpeg: it's a thin wrapper over exactly
4
+ * this (spawn + arg building), and cutting it removes a dependency that
5
+ * has historically been finicky to bundle into pkg/nexe binaries. If a
6
+ * host application would rather use fluent-ffmpeg for its richer filter
7
+ * DSL, it can — this function's signature (args in, stdout/stderr out) is
8
+ * intentionally the same shape fluent-ffmpeg or any other wrapper would
9
+ * need, so swapping it later doesn't touch render.ts's call sites.
10
+ */
11
+ export function runFfmpeg(args, options = {}) {
12
+ const bin = options.ffmpegPath ?? "ffmpeg";
13
+ return new Promise((resolve, reject) => {
14
+ const proc = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
15
+ let stdout = "";
16
+ let stderr = "";
17
+ proc.stdout.on("data", (chunk) => (stdout += chunk.toString()));
18
+ proc.stderr.on("data", (chunk) => (stderr += chunk.toString()));
19
+ proc.on("error", (err) => {
20
+ reject(new Error(`Failed to launch ffmpeg ("${bin}"). Is it installed and on PATH? ${err.message}`));
21
+ });
22
+ proc.on("close", (code) => {
23
+ if (code === 0) {
24
+ resolve({ stdout, stderr });
25
+ }
26
+ else {
27
+ reject(new Error(`ffmpeg exited with code ${code}:\n${stderr.slice(-4000)}`));
28
+ }
29
+ });
30
+ });
31
+ }
32
+ //# sourceMappingURL=ffmpegBinary.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ffmpegBinary.js","sourceRoot":"","sources":["../../src/ffmpeg/ffmpegBinary.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAE3C;;;;;;;;GAQG;AACH,MAAM,UAAU,SAAS,CACvB,IAAc,EACd,UAAmC,EAAE;IAErC,MAAM,GAAG,GAAG,OAAO,CAAC,UAAU,IAAI,QAAQ,CAAC;IAC3C,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,IAAI,GAAG,KAAK,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;QACrE,IAAI,MAAM,GAAG,EAAE,CAAC;QAChB,IAAI,MAAM,GAAG,EAAE,CAAC;QAEhB,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,MAAM,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;QAChE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,MAAM,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;QAEhE,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE;YACvB,MAAM,CACJ,IAAI,KAAK,CACP,6BAA6B,GAAG,oCAAoC,GAAG,CAAC,OAAO,EAAE,CAClF,CACF,CAAC;QACJ,CAAC,CAAC,CAAC;QAEH,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE;YACxB,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC;gBACf,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;YAC9B,CAAC;iBAAM,CAAC;gBACN,MAAM,CAAC,IAAI,KAAK,CAAC,2BAA2B,IAAI,MAAM,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;YAChF,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,22 @@
1
+ import type { LayerInstruction } from "../types.js";
2
+ /**
3
+ * Builds a `-filter_complex` graph string for a set of layers applied on
4
+ * top of the base video stream `[0:v]`. This is intentionally a first
5
+ * pass covering "text" and "shape" layers directly (no extra ffmpeg
6
+ * inputs required) and "image"/"video" layers via additional `-i` inputs
7
+ * supplied by the caller. It favors correctness and readability over
8
+ * covering every ffmpeg filter — extending it with blend modes, masks, or
9
+ * transitions is additive, not a redesign.
10
+ */
11
+ export declare function buildFilterGraph(layers: LayerInstruction[], extraInputIndexByLayerId: Map<string, number>): string;
12
+ export interface RenderOptions {
13
+ baseVideoPath: string;
14
+ layers: LayerInstruction[];
15
+ outputPath: string;
16
+ ffmpegPath?: string;
17
+ /** Extra input file paths for "image"/"video" layers, keyed by layer id. */
18
+ extraInputsByLayerId?: Map<string, string>;
19
+ }
20
+ /** Renders the standard flattened playback video (no embedded payload yet). */
21
+ export declare function renderFlattenedVideo(options: RenderOptions): Promise<void>;
22
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/ffmpeg/render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAWpD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,gBAAgB,EAAE,EAC1B,wBAAwB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAC5C,MAAM,CA0DR;AAED,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,oBAAoB,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC5C;AAED,+EAA+E;AAC/E,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBhF"}
@@ -0,0 +1,88 @@
1
+ import { runFfmpeg } from "./ffmpegBinary.js";
2
+ /**
3
+ * Escapes text for safe embedding inside an ffmpeg filtergraph string
4
+ * (drawtext text= values in particular are picky about colons/quotes).
5
+ */
6
+ function escapeForFilter(s) {
7
+ return s.replace(/\\/g, "\\\\").replace(/:/g, "\\:").replace(/'/g, "\\'");
8
+ }
9
+ /**
10
+ * Builds a `-filter_complex` graph string for a set of layers applied on
11
+ * top of the base video stream `[0:v]`. This is intentionally a first
12
+ * pass covering "text" and "shape" layers directly (no extra ffmpeg
13
+ * inputs required) and "image"/"video" layers via additional `-i` inputs
14
+ * supplied by the caller. It favors correctness and readability over
15
+ * covering every ffmpeg filter — extending it with blend modes, masks, or
16
+ * transitions is additive, not a redesign.
17
+ */
18
+ export function buildFilterGraph(layers, extraInputIndexByLayerId) {
19
+ let label = "0:v";
20
+ const parts = [];
21
+ layers.forEach((layer, i) => {
22
+ const nextLabel = `v${i}`;
23
+ const enable = `between(t\\,${layer.startTime}\\,${layer.endTime})`;
24
+ switch (layer.type) {
25
+ case "text": {
26
+ const text = escapeForFilter(String(layer.data.text ?? ""));
27
+ const fontsize = Number(layer.data.fontsize ?? 32);
28
+ const fontcolor = String(layer.data.fontcolor ?? "white");
29
+ parts.push(`[${label}]drawtext=text='${text}':x=${layer.transform.x}:y=${layer.transform.y}` +
30
+ `:fontsize=${fontsize}:fontcolor=${fontcolor}@${layer.opacity}` +
31
+ `:enable='${enable}'[${nextLabel}]`);
32
+ break;
33
+ }
34
+ case "shape": {
35
+ const color = String(layer.data.color ?? "black");
36
+ const w = Number(layer.data.width ?? 100);
37
+ const h = Number(layer.data.height ?? 100);
38
+ parts.push(`[${label}]drawbox=x=${layer.transform.x}:y=${layer.transform.y}` +
39
+ `:w=${w}:h=${h}:color=${color}@${layer.opacity}:t=fill` +
40
+ `:enable='${enable}'[${nextLabel}]`);
41
+ break;
42
+ }
43
+ case "image":
44
+ case "video": {
45
+ const inputIdx = extraInputIndexByLayerId.get(layer.id);
46
+ if (inputIdx === undefined) {
47
+ throw new Error(`Layer "${layer.id}" is type "${layer.type}" but has no associated ffmpeg input index`);
48
+ }
49
+ const scaled = `s${i}`;
50
+ parts.push(`[${inputIdx}:v]scale=iw*${layer.transform.scale}:ih*${layer.transform.scale}[${scaled}]`);
51
+ parts.push(`[${label}][${scaled}]overlay=x=${layer.transform.x}:y=${layer.transform.y}` +
52
+ `:enable='${enable}'[${nextLabel}]`);
53
+ break;
54
+ }
55
+ default: {
56
+ const _exhaustive = layer.type;
57
+ throw new Error(`Unhandled layer type: ${_exhaustive}`);
58
+ }
59
+ }
60
+ label = nextLabel;
61
+ });
62
+ // ffmpeg needs the final output labeled distinctly if any filters ran.
63
+ if (parts.length === 0)
64
+ return "";
65
+ return parts.join(";") + `;[${label}]null[outv]`;
66
+ }
67
+ /** Renders the standard flattened playback video (no embedded payload yet). */
68
+ export async function renderFlattenedVideo(options) {
69
+ const extraInputsByLayerId = options.extraInputsByLayerId ?? new Map();
70
+ const args = ["-y", "-i", options.baseVideoPath];
71
+ const extraInputIndexByLayerId = new Map();
72
+ let nextInputIndex = 1;
73
+ for (const [layerId, path] of extraInputsByLayerId) {
74
+ args.push("-i", path);
75
+ extraInputIndexByLayerId.set(layerId, nextInputIndex);
76
+ nextInputIndex += 1;
77
+ }
78
+ const graph = buildFilterGraph(options.layers, extraInputIndexByLayerId);
79
+ if (graph) {
80
+ args.push("-filter_complex", graph, "-map", "[outv]", "-map", "0:a?");
81
+ }
82
+ else {
83
+ args.push("-map", "0:v", "-map", "0:a?");
84
+ }
85
+ args.push("-c:v", "libx264", "-preset", "veryfast", "-c:a", "copy", options.outputPath);
86
+ await runFfmpeg(args, { ffmpegPath: options.ffmpegPath });
87
+ }
88
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.js","sourceRoot":"","sources":["../../src/ffmpeg/render.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAE9C;;;GAGG;AACH,SAAS,eAAe,CAAC,CAAS;IAChC,OAAO,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AAC5E,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAC9B,MAA0B,EAC1B,wBAA6C;IAE7C,IAAI,KAAK,GAAG,KAAK,CAAC;IAClB,MAAM,KAAK,GAAa,EAAE,CAAC;IAE3B,MAAM,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE;QAC1B,MAAM,SAAS,GAAG,IAAI,CAAC,EAAE,CAAC;QAC1B,MAAM,MAAM,GAAG,eAAe,KAAK,CAAC,SAAS,MAAM,KAAK,CAAC,OAAO,GAAG,CAAC;QAEpE,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAC;YACnB,KAAK,MAAM,CAAC,CAAC,CAAC;gBACZ,MAAM,IAAI,GAAG,eAAe,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC;gBAC5D,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC;gBACnD,MAAM,SAAS,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,IAAI,OAAO,CAAC,CAAC;gBAC1D,KAAK,CAAC,IAAI,CACR,IAAI,KAAK,mBAAmB,IAAI,OAAO,KAAK,CAAC,SAAS,CAAC,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,CAAC,EAAE;oBAC/E,aAAa,QAAQ,cAAc,SAAS,IAAI,KAAK,CAAC,OAAO,EAAE;oBAC/D,YAAY,MAAM,KAAK,SAAS,GAAG,CACtC,CAAC;gBACF,MAAM;YACR,CAAC;YACD,KAAK,OAAO,CAAC,CAAC,CAAC;gBACb,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,IAAI,OAAO,CAAC,CAAC;gBAClD,MAAM,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,IAAI,GAAG,CAAC,CAAC;gBAC1C,MAAM,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC;gBAC3C,KAAK,CAAC,IAAI,CACR,IAAI,KAAK,cAAc,KAAK,CAAC,SAAS,CAAC,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,CAAC,EAAE;oBAC/D,MAAM,CAAC,MAAM,CAAC,UAAU,KAAK,IAAI,KAAK,CAAC,OAAO,SAAS;oBACvD,YAAY,MAAM,KAAK,SAAS,GAAG,CACtC,CAAC;gBACF,MAAM;YACR,CAAC;YACD,KAAK,OAAO,CAAC;YACb,KAAK,OAAO,CAAC,CAAC,CAAC;gBACb,MAAM,QAAQ,GAAG,wBAAwB,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;gBACxD,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;oBAC3B,MAAM,IAAI,KAAK,CACb,UAAU,KAAK,CAAC,EAAE,cAAc,KAAK,CAAC,IAAI,4CAA4C,CACvF,CAAC;gBACJ,CAAC;gBACD,MAAM,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC;gBACvB,KAAK,CAAC,IAAI,CAAC,IAAI,QAAQ,eAAe,KAAK,CAAC,SAAS,CAAC,KAAK,OAAO,KAAK,CAAC,SAAS,CAAC,KAAK,IAAI,MAAM,GAAG,CAAC,CAAC;gBACtG,KAAK,CAAC,IAAI,CACR,IAAI,KAAK,KAAK,MAAM,cAAc,KAAK,CAAC,SAAS,CAAC,CAAC,MAAM,KAAK,CAAC,SAAS,CAAC,CAAC,EAAE;oBAC1E,YAAY,MAAM,KAAK,SAAS,GAAG,CACtC,CAAC;gBACF,MAAM;YACR,CAAC;YACD,OAAO,CAAC,CAAC,CAAC;gBACR,MAAM,WAAW,GAAU,KAAK,CAAC,IAAI,CAAC;gBACtC,MAAM,IAAI,KAAK,CAAC,yBAAyB,WAAW,EAAE,CAAC,CAAC;YAC1D,CAAC;QACH,CAAC;QACD,KAAK,GAAG,SAAS,CAAC;IACpB,CAAC,CAAC,CAAC;IAEH,uEAAuE;IACvE,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAClC,OAAO,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,KAAK,aAAa,CAAC;AACnD,CAAC;AAWD,+EAA+E;AAC/E,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,OAAsB;IAC/D,MAAM,oBAAoB,GAAG,OAAO,CAAC,oBAAoB,IAAI,IAAI,GAAG,EAAE,CAAC;IACvE,MAAM,IAAI,GAAa,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;IAE3D,MAAM,wBAAwB,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC3D,IAAI,cAAc,GAAG,CAAC,CAAC;IACvB,KAAK,MAAM,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,oBAAoB,EAAE,CAAC;QACnD,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QACtB,wBAAwB,CAAC,GAAG,CAAC,OAAO,EAAE,cAAc,CAAC,CAAC;QACtD,cAAc,IAAI,CAAC,CAAC;IACtB,CAAC;IAED,MAAM,KAAK,GAAG,gBAAgB,CAAC,OAAO,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC;IAEzE,IAAI,KAAK,EAAE,CAAC;QACV,IAAI,CAAC,IAAI,CAAC,iBAAiB,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACxE,CAAC;SAAM,CAAC;QACN,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3C,CAAC;IAED,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,UAAU,CAAC,CAAC;IAExF,MAAM,SAAS,CAAC,IAAI,EAAE,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;AAC5D,CAAC"}
@@ -0,0 +1,3 @@
1
+ /** Top-level ISO-BMFF box type used to carry the embedded Zephyra payload. */
2
+ export declare const ZEPHYRA_BOX_TYPE = "zeph";
3
+ //# sourceMappingURL=format.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,eAAO,MAAM,gBAAgB,SAAS,CAAC"}