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.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/dist/container/mp4Box.d.ts +46 -0
- package/dist/container/mp4Box.d.ts.map +1 -0
- package/dist/container/mp4Box.js +115 -0
- package/dist/container/mp4Box.js.map +1 -0
- package/dist/deconstructor.d.ts +15 -0
- package/dist/deconstructor.d.ts.map +1 -0
- package/dist/deconstructor.js +40 -0
- package/dist/deconstructor.js.map +1 -0
- package/dist/exporter.d.ts +11 -0
- package/dist/exporter.d.ts.map +1 -0
- package/dist/exporter.js +51 -0
- package/dist/exporter.js.map +1 -0
- package/dist/ffmpeg/ffmpegBinary.d.ts +16 -0
- package/dist/ffmpeg/ffmpegBinary.d.ts.map +1 -0
- package/dist/ffmpeg/ffmpegBinary.js +32 -0
- package/dist/ffmpeg/ffmpegBinary.js.map +1 -0
- package/dist/ffmpeg/render.d.ts +22 -0
- package/dist/ffmpeg/render.d.ts.map +1 -0
- package/dist/ffmpeg/render.js +88 -0
- package/dist/ffmpeg/render.js.map +1 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +3 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/serialization/msgpack.d.ts +26 -0
- package/dist/serialization/msgpack.d.ts.map +1 -0
- package/dist/serialization/msgpack.js +334 -0
- package/dist/serialization/msgpack.js.map +1 -0
- package/dist/serialization/payload.d.ts +16 -0
- package/dist/serialization/payload.d.ts.map +1 -0
- package/dist/serialization/payload.js +80 -0
- package/dist/serialization/payload.js.map +1 -0
- package/dist/types.d.ts +97 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/hash.d.ts +2 -0
- package/dist/utils/hash.d.ts.map +1 -0
- package/dist/utils/hash.js +5 -0
- package/dist/utils/hash.js.map +1 -0
- 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":"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"}
|
|
@@ -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"}
|
package/dist/exporter.js
ADDED
|
@@ -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"}
|
package/dist/format.d.ts
ADDED
|
@@ -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"}
|