@nebutra/video-compose 0.2.2 → 0.2.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/package.json +15 -5
- package/.turbo/turbo-build.log +0 -17
- package/.turbo/turbo-test.log +0 -14
- package/.turbo/turbo-typecheck.log +0 -4
- package/src/__tests__/video-compose.test.ts +0 -67
- package/src/index.ts +0 -141
- package/tsconfig.json +0 -10
- package/tsup.config.ts +0 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebutra/video-compose",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Pure timeline / edit-decision-list builder + provider-agnostic video compositor (concat, crossfade transitions, mux). Zero-config deterministic mock default; ffmpeg adapter documented.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,13 +10,17 @@
|
|
|
10
10
|
"category": "integrations",
|
|
11
11
|
"summary": "Timeline builder + multi-tenant video compositor abstraction"
|
|
12
12
|
},
|
|
13
|
-
"main": "./
|
|
14
|
-
"types": "./
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
15
|
"exports": {
|
|
16
|
-
".":
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
17
21
|
},
|
|
18
22
|
"dependencies": {
|
|
19
|
-
"@nebutra/capability-kit": "0.2.
|
|
23
|
+
"@nebutra/capability-kit": "0.2.4",
|
|
20
24
|
"@nebutra/provider-factory": "0.2.2"
|
|
21
25
|
},
|
|
22
26
|
"devDependencies": {
|
|
@@ -37,6 +41,12 @@
|
|
|
37
41
|
"publishConfig": {
|
|
38
42
|
"access": "public"
|
|
39
43
|
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE",
|
|
48
|
+
"CHANGELOG.md"
|
|
49
|
+
],
|
|
40
50
|
"scripts": {
|
|
41
51
|
"build": "tsup",
|
|
42
52
|
"test": "vitest run",
|
package/.turbo/turbo-build.log
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
> @nebutra/video-compose@0.2.2 build /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/integrations/video-compose
|
|
3
|
-
> tsup
|
|
4
|
-
|
|
5
|
-
CLI Building entry: src/index.ts
|
|
6
|
-
CLI Using tsconfig: tsconfig.json
|
|
7
|
-
CLI tsup v8.5.1
|
|
8
|
-
CLI Using tsup config: /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/integrations/video-compose/tsup.config.ts
|
|
9
|
-
CLI Target: es2022
|
|
10
|
-
CLI Cleaning output folder
|
|
11
|
-
ESM Build start
|
|
12
|
-
ESM dist/index.js 2.87 KB
|
|
13
|
-
ESM dist/index.js.map 6.72 KB
|
|
14
|
-
ESM ⚡️ Build success in 10ms
|
|
15
|
-
DTS Build start
|
|
16
|
-
DTS ⚡️ Build success in 2721ms
|
|
17
|
-
DTS dist/index.d.ts 2.49 KB
|
package/.turbo/turbo-test.log
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
> @nebutra/video-compose@0.2.1 test /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/integrations/video-compose
|
|
3
|
-
> vitest run
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
RUN v4.1.4 /Users/tseka_luk/Documents/Nebutra-SaaS-Lab/Nebutra-Sailor/packages/integrations/video-compose
|
|
7
|
-
|
|
8
|
-
✓ src/__tests__/video-compose.test.ts (9 tests) 10ms
|
|
9
|
-
|
|
10
|
-
Test Files 1 passed (1)
|
|
11
|
-
Tests 9 passed (9)
|
|
12
|
-
Start at 09:38:36
|
|
13
|
-
Duration 270ms (transform 44ms, setup 0ms, import 60ms, tests 10ms, environment 0ms)
|
|
14
|
-
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { composeTimeline, getVideoCompose, MockCompositor, VideoComposeError } from "../index";
|
|
3
|
-
|
|
4
|
-
const clips = [
|
|
5
|
-
{ uri: "mp4:a", durationSec: 4 },
|
|
6
|
-
{ uri: "mp4:b", durationSec: 6 },
|
|
7
|
-
{ uri: "mp4:c", durationSec: 2 },
|
|
8
|
-
];
|
|
9
|
-
|
|
10
|
-
describe("composeTimeline", () => {
|
|
11
|
-
it("concatenates: cumulative starts, total = sum", () => {
|
|
12
|
-
const t = composeTimeline(clips);
|
|
13
|
-
expect(t.totalDurationSec).toBe(12);
|
|
14
|
-
expect(t.segments.map((s) => s.startSec)).toEqual([0, 4, 10]);
|
|
15
|
-
expect(t.segments.map((s) => s.uri)).toEqual(["mp4:a", "mp4:b", "mp4:c"]);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("crossfade overlaps each next clip, shortening the total", () => {
|
|
19
|
-
const t = composeTimeline(clips, { crossfadeSec: 1 });
|
|
20
|
-
// total = 12 - 1*(3-1) = 10; starts overlap by the crossfade
|
|
21
|
-
expect(t.totalDurationSec).toBe(10);
|
|
22
|
-
expect(t.segments.map((s) => s.startSec)).toEqual([0, 3, 8]);
|
|
23
|
-
expect(t.segments[1]?.transitionInSec).toBe(1);
|
|
24
|
-
expect(t.segments[0]?.transitionInSec ?? 0).toBe(0);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("starts are strictly monotonic", () => {
|
|
28
|
-
const t = composeTimeline(clips, { crossfadeSec: 1.5 });
|
|
29
|
-
for (let i = 1; i < t.segments.length; i++) {
|
|
30
|
-
expect(t.segments[i]!.startSec).toBeGreaterThan(t.segments[i - 1]!.startSec);
|
|
31
|
-
}
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("rejects empty clip list", () => {
|
|
35
|
-
expect(() => composeTimeline([])).toThrow(VideoComposeError);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("rejects a crossfade not shorter than the shortest clip", () => {
|
|
39
|
-
expect(() => composeTimeline(clips, { crossfadeSec: 2 })).toThrow(/crossfade/i);
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe("MockCompositor", () => {
|
|
44
|
-
it("renders a deterministic descriptor whose duration matches the timeline", async () => {
|
|
45
|
-
const t = composeTimeline(clips);
|
|
46
|
-
const c = new MockCompositor();
|
|
47
|
-
const a = await c.render(t);
|
|
48
|
-
const b = await c.render(t);
|
|
49
|
-
expect(a).toEqual(b);
|
|
50
|
-
expect(a.durationSec).toBe(12);
|
|
51
|
-
expect(a.uri.startsWith("data:video/")).toBe(true);
|
|
52
|
-
});
|
|
53
|
-
it("health() ok", async () => {
|
|
54
|
-
expect((await new MockCompositor().health()).ok).toBe(true);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe("getVideoCompose", () => {
|
|
59
|
-
it("zero-config → working mock compositor", async () => {
|
|
60
|
-
const vc = await getVideoCompose();
|
|
61
|
-
const out = await vc.render(composeTimeline(clips));
|
|
62
|
-
expect(out.uri.startsWith("data:video/")).toBe(true);
|
|
63
|
-
});
|
|
64
|
-
it("ffmpeg provider is a fail-loud stub until landed", async () => {
|
|
65
|
-
await expect(getVideoCompose({ provider: "ffmpeg" })).rejects.toBeInstanceOf(VideoComposeError);
|
|
66
|
-
});
|
|
67
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @nebutra/video-compose — the video-pipeline assembly gap.
|
|
3
|
-
*
|
|
4
|
-
* `composeTimeline` is pure (an edit-decision list: ordered segments +
|
|
5
|
-
* crossfade math + total duration) so the assembly logic is unit-testable
|
|
6
|
-
* with no ffmpeg binary. The `VideoCompositor` that actually renders is a
|
|
7
|
-
* provider abstraction: a deterministic zero-config mock is the active
|
|
8
|
-
* default; a real ffmpeg adapter is selected via the shared provider pattern
|
|
9
|
-
* and fails loud until landed (stub lifecycle).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { CapabilityError } from "@nebutra/capability-kit";
|
|
13
|
-
import { resolveProviderType } from "@nebutra/provider-factory";
|
|
14
|
-
|
|
15
|
-
export class VideoComposeError extends CapabilityError {
|
|
16
|
-
constructor(message: string, init: { code: string; suggestion: string; cause?: unknown }) {
|
|
17
|
-
super(message, init, {
|
|
18
|
-
name: "VideoComposeError",
|
|
19
|
-
emptySuggestionFallback:
|
|
20
|
-
"No suggestion was provided. This is a bug in @nebutra/video-compose — report it.",
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface Clip {
|
|
26
|
-
readonly uri: string;
|
|
27
|
-
readonly durationSec: number;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface TimelineSegment {
|
|
31
|
-
readonly uri: string;
|
|
32
|
-
readonly startSec: number;
|
|
33
|
-
readonly durationSec: number;
|
|
34
|
-
/** Crossfade-in length from the previous segment (0 for the first). */
|
|
35
|
-
readonly transitionInSec: number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface Timeline {
|
|
39
|
-
readonly segments: readonly TimelineSegment[];
|
|
40
|
-
readonly totalDurationSec: number;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface ComposeOptions {
|
|
44
|
-
/** Crossfade overlap between adjacent clips; must be < shortest clip. */
|
|
45
|
-
readonly crossfadeSec?: number;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Build the edit-decision list. Pure; no I/O. */
|
|
49
|
-
export function composeTimeline(clips: readonly Clip[], opts: ComposeOptions = {}): Timeline {
|
|
50
|
-
if (clips.length === 0) {
|
|
51
|
-
throw new VideoComposeError("Cannot compose a timeline from zero clips.", {
|
|
52
|
-
code: "VC_NO_CLIPS",
|
|
53
|
-
suggestion: "Pass at least one rendered clip.",
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
const cf = opts.crossfadeSec ?? 0;
|
|
57
|
-
if (cf < 0) {
|
|
58
|
-
throw new VideoComposeError("crossfadeSec must be non-negative.", {
|
|
59
|
-
code: "VC_BAD_CROSSFADE",
|
|
60
|
-
suggestion: "Use a crossfade ≥ 0 and strictly shorter than the shortest clip.",
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
const shortest = Math.min(...clips.map((c) => c.durationSec));
|
|
64
|
-
if (cf > 0 && cf >= shortest) {
|
|
65
|
-
throw new VideoComposeError(
|
|
66
|
-
`crossfadeSec (${cf}) must be shorter than the shortest clip (${shortest}).`,
|
|
67
|
-
{
|
|
68
|
-
code: "VC_CROSSFADE_TOO_LONG",
|
|
69
|
-
suggestion: "Reduce the crossfade or remove clips shorter than it.",
|
|
70
|
-
},
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const segments: TimelineSegment[] = [];
|
|
75
|
-
let cursor = 0;
|
|
76
|
-
clips.forEach((clip, i) => {
|
|
77
|
-
const transitionInSec = i === 0 ? 0 : cf;
|
|
78
|
-
const startSec = i === 0 ? 0 : cursor - cf;
|
|
79
|
-
segments.push({ uri: clip.uri, startSec, durationSec: clip.durationSec, transitionInSec });
|
|
80
|
-
cursor = startSec + clip.durationSec;
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
return { segments, totalDurationSec: cursor };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export type VideoComposeProviderType = "mock" | "ffmpeg";
|
|
87
|
-
|
|
88
|
-
export interface VideoCompositor {
|
|
89
|
-
readonly name: VideoComposeProviderType;
|
|
90
|
-
render(timeline: Timeline): Promise<{ uri: string; durationSec: number }>;
|
|
91
|
-
health(): Promise<{ ok: boolean; detail: string }>;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function hash(s: string): string {
|
|
95
|
-
let h = 2166136261;
|
|
96
|
-
for (let i = 0; i < s.length; i++) {
|
|
97
|
-
h ^= s.charCodeAt(i);
|
|
98
|
-
h = Math.imul(h, 16777619);
|
|
99
|
-
}
|
|
100
|
-
return (h >>> 0).toString(36);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Deterministic zero-config compositor (no ffmpeg). */
|
|
104
|
-
export class MockCompositor implements VideoCompositor {
|
|
105
|
-
readonly name = "mock" as const;
|
|
106
|
-
|
|
107
|
-
async render(timeline: Timeline): Promise<{ uri: string; durationSec: number }> {
|
|
108
|
-
const sig = hash(
|
|
109
|
-
timeline.segments.map((s) => `${s.uri}@${s.startSec}+${s.transitionInSec}`).join("|"),
|
|
110
|
-
);
|
|
111
|
-
return {
|
|
112
|
-
uri: `data:video/x-nebutra-mock;base64,${Buffer.from(sig).toString("base64")}`,
|
|
113
|
-
durationSec: timeline.totalDurationSec,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async health(): Promise<{ ok: boolean; detail: string }> {
|
|
118
|
-
return { ok: true, detail: "mock compositor: deterministic, zero-config" };
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export interface VideoComposeConfig {
|
|
123
|
-
readonly provider?: VideoComposeProviderType;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Resolve a compositor. Zero-config → mock; ffmpeg is a fail-loud stub. */
|
|
127
|
-
export async function getVideoCompose(config?: VideoComposeConfig): Promise<VideoCompositor> {
|
|
128
|
-
const type = resolveProviderType<VideoComposeProviderType>({
|
|
129
|
-
explicit: config?.provider,
|
|
130
|
-
envVarName: "VIDEO_COMPOSE_PROVIDER",
|
|
131
|
-
detectors: [],
|
|
132
|
-
fallback: "mock",
|
|
133
|
-
});
|
|
134
|
-
if (type === "mock") return new MockCompositor();
|
|
135
|
-
throw new VideoComposeError(`The "${type}" compositor is not implemented yet.`, {
|
|
136
|
-
code: "VC_PROVIDER_UNIMPLEMENTED",
|
|
137
|
-
suggestion:
|
|
138
|
-
"Implement the ffmpeg adapter (render via fluent-ffmpeg/child_process + " +
|
|
139
|
-
"health) and register it here, or use the zero-config mock compositor.",
|
|
140
|
-
});
|
|
141
|
-
}
|
package/tsconfig.json
DELETED