@editframe/elements 0.6.0-beta.16 → 0.6.0-beta.17

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.
@@ -0,0 +1,384 @@
1
+ import { LitElement, type PropertyValueMap, css } from "lit";
2
+ import { EFTemporal } from "./EFTemporal";
3
+ import { property, state } from "lit/decorators.js";
4
+ import { deepArrayEquals } from "@lit/task/deep-equals.js";
5
+ import { Task } from "@lit/task";
6
+ import type * as MP4Box from "mp4box";
7
+ import { MP4File } from "@/av/MP4File";
8
+ import type { TrackFragmentIndex, TrackSegment } from "@editframe/assets";
9
+ import { getStartTimeMs } from "./util";
10
+ import { VideoAsset } from "@/av/EncodedAsset";
11
+ import { FetchMixin } from "./FetchMixin";
12
+ import { apiHostContext } from "../gui/EFWorkbench";
13
+ import { consume } from "@lit/context";
14
+ import { EFSourceMixin } from "./EFSourceMixin";
15
+ import { EF_INTERACTIVE } from "../EF_INTERACTIVE";
16
+
17
+ export const deepGetMediaElements = (
18
+ element: Element,
19
+ medias: EFMedia[] = [],
20
+ ) => {
21
+ for (const child of Array.from(element.children)) {
22
+ if (child instanceof EFMedia) {
23
+ medias.push(child);
24
+ } else {
25
+ deepGetMediaElements(child, medias);
26
+ }
27
+ }
28
+ return medias;
29
+ };
30
+
31
+ export class EFMedia extends EFSourceMixin(EFTemporal(FetchMixin(LitElement)), {
32
+ assetType: "isobmff_files",
33
+ }) {
34
+ static styles = [
35
+ css`
36
+ :host {
37
+ display: block;
38
+ position: relative;
39
+ overflow: hidden;
40
+ }
41
+ `,
42
+ ];
43
+
44
+ @property({ type: Number })
45
+ currentTimeMs = 0;
46
+
47
+ @consume({ context: apiHostContext, subscribe: true })
48
+ @state()
49
+ efHost?: string;
50
+
51
+ fragmentIndexPath() {
52
+ if (this.src.startsWith("editframe://") || this.src.startsWith("http")) {
53
+ return `${this.src}/index`;
54
+ }
55
+ return `/@ef-track-fragment-index/${this.getAttribute("src") ?? ""}`;
56
+ }
57
+
58
+ fragmentTrackPath(trackId: string) {
59
+ if (this.src.startsWith("editframe://") || this.src.startsWith("http")) {
60
+ return `${this.src.replace("files", "tracks")}/${trackId}`;
61
+ }
62
+ return `/@ef-track/${this.src ?? ""}?trackId=${trackId}`;
63
+ }
64
+
65
+ public trackFragmentIndexLoader = new Task(this, {
66
+ args: () => [this.fragmentIndexPath(), this.fetch] as const,
67
+ task: async ([fragmentIndexPath, fetch], { signal }) => {
68
+ const response = await fetch(fragmentIndexPath, { signal });
69
+ return (await response.json()) as Record<number, TrackFragmentIndex>;
70
+ },
71
+ onComplete: () => {
72
+ this.requestUpdate("ownCurrentTimeMs");
73
+ this.rootTimegroup?.requestUpdate("ownCurrentTimeMs");
74
+ },
75
+ });
76
+
77
+ protected initSegmentsLoader = new Task(this, {
78
+ autoRun: EF_INTERACTIVE,
79
+ args: () =>
80
+ [this.trackFragmentIndexLoader.value, this.src, this.fetch] as const,
81
+ task: async ([fragmentIndex, _src, fetch], { signal }) => {
82
+ if (!fragmentIndex) {
83
+ return;
84
+ }
85
+ return await Promise.all(
86
+ Object.entries(fragmentIndex).map(async ([trackId, track]) => {
87
+ const start = track.initSegment.offset;
88
+ const end = track.initSegment.offset + track.initSegment.size - 1;
89
+ const response = await fetch(this.fragmentTrackPath(trackId), {
90
+ signal,
91
+ headers: { Range: `bytes=${start}-${end}` },
92
+ });
93
+ const buffer =
94
+ (await response.arrayBuffer()) as MP4Box.MP4ArrayBuffer;
95
+ buffer.fileStart = 0;
96
+ const mp4File = new MP4File();
97
+ mp4File.appendBuffer(buffer, true);
98
+ mp4File.flush();
99
+ await mp4File.readyPromise;
100
+
101
+ return { trackId, buffer, mp4File };
102
+ }),
103
+ );
104
+ },
105
+ });
106
+
107
+ get defaultVideoTrackId() {
108
+ return Object.values(this.trackFragmentIndexLoader.value ?? {}).find(
109
+ (track) => track.type === "video",
110
+ )?.track;
111
+ }
112
+
113
+ get defaultAudioTrackId() {
114
+ return Object.values(this.trackFragmentIndexLoader.value ?? {}).find(
115
+ (track) => track.type === "audio",
116
+ )?.track;
117
+ }
118
+
119
+ seekTask = new Task(this, {
120
+ autoRun: EF_INTERACTIVE,
121
+ args: () =>
122
+ [
123
+ this.desiredSeekTimeMs,
124
+ this.trackFragmentIndexLoader.value,
125
+ this.initSegmentsLoader.value,
126
+ ] as const,
127
+ task: async (
128
+ [seekToMs, fragmentIndex, initSegments],
129
+ { signal: _signal },
130
+ ) => {
131
+ if (fragmentIndex === undefined) {
132
+ return;
133
+ }
134
+ if (initSegments === undefined) {
135
+ return;
136
+ }
137
+
138
+ const result: Record<
139
+ string,
140
+ { segment: TrackSegment; track: MP4Box.TrackInfo }
141
+ > = {};
142
+
143
+ for (const index of Object.values(fragmentIndex)) {
144
+ const track = initSegments
145
+ .find((segment) => segment.trackId === String(index.track))
146
+ ?.mp4File.getInfo().tracks[0];
147
+
148
+ if (!track) {
149
+ throw new Error("Could not finding matching track");
150
+ }
151
+
152
+ const segment = index.segments.toReversed().find((segment) => {
153
+ return (segment.dts / track.timescale) * 1000 <= seekToMs;
154
+ });
155
+
156
+ if (!segment) {
157
+ return;
158
+ }
159
+
160
+ result[index.track] = { segment, track };
161
+ }
162
+
163
+ return result;
164
+ },
165
+ });
166
+
167
+ fetchSeekTask = new Task(this, {
168
+ autoRun: EF_INTERACTIVE,
169
+ argsEqual: deepArrayEquals,
170
+ args: () =>
171
+ [this.initSegmentsLoader.value, this.seekTask.value, this.fetch] as const,
172
+ task: async ([initSegments, seekResult, fetch], { signal }) => {
173
+ if (!initSegments) {
174
+ return;
175
+ }
176
+ if (!seekResult) {
177
+ return;
178
+ }
179
+
180
+ const files: Record<string, File> = {};
181
+
182
+ for (const [trackId, { segment, track }] of Object.entries(seekResult)) {
183
+ const start = segment.offset;
184
+ const end = segment.offset + segment.size;
185
+
186
+ const response = await fetch(this.fragmentTrackPath(trackId), {
187
+ signal,
188
+ headers: { Range: `bytes=${start}-${end}` },
189
+ });
190
+
191
+ const initSegment = Object.values(initSegments).find(
192
+ (initSegment) => initSegment.trackId === String(track.id),
193
+ );
194
+ if (!initSegment) {
195
+ throw new Error("Could not find matching init segment");
196
+ }
197
+ const initBuffer = initSegment.buffer;
198
+
199
+ const mediaBuffer =
200
+ (await response.arrayBuffer()) as unknown as MP4Box.MP4ArrayBuffer;
201
+
202
+ files[trackId] = new File([initBuffer, mediaBuffer], "video.mp4", {
203
+ type: "video/mp4",
204
+ });
205
+ }
206
+
207
+ return files;
208
+ },
209
+ });
210
+
211
+ videoAssetTask = new Task(this, {
212
+ autoRun: EF_INTERACTIVE,
213
+ args: () => [this.fetchSeekTask.value] as const,
214
+ task: async ([files], { signal: _signal }) => {
215
+ if (!files) {
216
+ return;
217
+ }
218
+ if (!this.defaultVideoTrackId) {
219
+ return;
220
+ }
221
+ const videoFile = files[this.defaultVideoTrackId];
222
+ if (!videoFile) {
223
+ return;
224
+ }
225
+ // TODO: Extract to general cleanup function
226
+ for (const frame of this.videoAssetTask.value?.decodedFrames || []) {
227
+ frame.close();
228
+ }
229
+ this.videoAssetTask.value?.videoDecoder?.close();
230
+ return await VideoAsset.createFromReadableStream(
231
+ "video.mp4",
232
+ videoFile.stream(),
233
+ videoFile,
234
+ );
235
+ },
236
+ });
237
+
238
+ @state() desiredSeekTimeMs = 0;
239
+
240
+ protected async executeSeek(seekToMs: number) {
241
+ this.desiredSeekTimeMs = seekToMs;
242
+ }
243
+
244
+ protected updated(
245
+ changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>,
246
+ ): void {
247
+ if (changedProperties.has("ownCurrentTimeMs")) {
248
+ this.executeSeek(this.ownCurrentTimeMs);
249
+ }
250
+ }
251
+
252
+ get hasOwnDuration() {
253
+ return true;
254
+ }
255
+
256
+ get durationMs() {
257
+ if (!this.trackFragmentIndexLoader.value) {
258
+ return 0;
259
+ }
260
+
261
+ const durations = Object.values(this.trackFragmentIndexLoader.value).map(
262
+ (track) => {
263
+ return (track.duration / track.timescale) * 1000;
264
+ },
265
+ );
266
+ if (durations.length === 0) {
267
+ return 0;
268
+ }
269
+ return Math.max(...durations);
270
+ }
271
+
272
+ get startTimeMs() {
273
+ return getStartTimeMs(this);
274
+ }
275
+
276
+ #audioContext = new OfflineAudioContext(2, 48000 / 30, 48000);
277
+
278
+ audioBufferTask = new Task(this, {
279
+ autoRun: EF_INTERACTIVE,
280
+ args: () => [this.fetchSeekTask.value, this.seekTask.value] as const,
281
+ task: async ([files, segments], { signal: _signal }) => {
282
+ if (!files) {
283
+ return;
284
+ }
285
+ if (!segments) {
286
+ return;
287
+ }
288
+ if (!this.defaultAudioTrackId) {
289
+ return;
290
+ }
291
+ const segment = segments[this.defaultAudioTrackId];
292
+ if (!segment) {
293
+ return;
294
+ }
295
+ const audioFile = files[this.defaultAudioTrackId];
296
+ if (!audioFile) {
297
+ return;
298
+ }
299
+ return {
300
+ buffer: await this.#audioContext.decodeAudioData(
301
+ await audioFile.arrayBuffer(),
302
+ ),
303
+ startOffsetMs: (segment.segment.cts / segment.track.timescale) * 1000,
304
+ };
305
+ },
306
+ });
307
+
308
+ async fetchAudioSpanningTime(fromMs: number, toMs: number) {
309
+ // Adjust range for track's own time
310
+ fromMs -= this.startTimeMs;
311
+ toMs -= this.startTimeMs;
312
+
313
+ await this.trackFragmentIndexLoader.taskComplete;
314
+ const audioTrackId = this.defaultAudioTrackId;
315
+ if (!audioTrackId) {
316
+ console.warn("No audio track found");
317
+ return;
318
+ }
319
+ const audioTrackIndex = this.trackFragmentIndexLoader.value?.[audioTrackId];
320
+ if (!audioTrackIndex) {
321
+ console.warn("No audio track found");
322
+ return;
323
+ }
324
+
325
+ const start = audioTrackIndex.initSegment.offset;
326
+ const end =
327
+ audioTrackIndex.initSegment.offset + audioTrackIndex.initSegment.size - 1;
328
+ const audioInitFragmentRequest = this.fetch(
329
+ this.fragmentTrackPath(String(audioTrackId)),
330
+ {
331
+ headers: { Range: `bytes=${start}-${end}` },
332
+ },
333
+ );
334
+
335
+ const fragments = Object.values(audioTrackIndex.segments).filter(
336
+ (segment) => {
337
+ const segmentStartsBeforeEnd =
338
+ segment.dts <= (toMs * audioTrackIndex.timescale) / 1000;
339
+ const segmentEndsAfterStart =
340
+ segment.dts + segment.duration >=
341
+ (fromMs * audioTrackIndex.timescale) / 1000;
342
+ return segmentStartsBeforeEnd && segmentEndsAfterStart;
343
+ },
344
+ );
345
+
346
+ const firstFragment = fragments[0];
347
+ if (!firstFragment) {
348
+ console.warn("No audio fragments found");
349
+ return;
350
+ }
351
+ const lastFragment = fragments[fragments.length - 1];
352
+ if (!lastFragment) {
353
+ console.warn("No audio fragments found");
354
+ return;
355
+ }
356
+ const fragmentStart = firstFragment.offset;
357
+ const fragmentEnd = lastFragment.offset + lastFragment.size - 1;
358
+
359
+ const audioFragmentRequest = this.fetch(
360
+ this.fragmentTrackPath(String(audioTrackId)),
361
+ {
362
+ headers: { Range: `bytes=${fragmentStart}-${fragmentEnd}` },
363
+ },
364
+ );
365
+
366
+ const initResponse = await audioInitFragmentRequest;
367
+ const dataResponse = await audioFragmentRequest;
368
+
369
+ const initBuffer = await initResponse.arrayBuffer();
370
+ const dataBuffer = await dataResponse.arrayBuffer();
371
+
372
+ const audioBlob = new Blob([initBuffer, dataBuffer], {
373
+ type: "audio/mp4",
374
+ });
375
+
376
+ return {
377
+ blob: audioBlob,
378
+ startMs: (firstFragment.dts / audioTrackIndex.timescale) * 1000,
379
+ endMs:
380
+ (lastFragment.dts / audioTrackIndex.timescale) * 1000 +
381
+ (lastFragment.duration / audioTrackIndex.timescale) * 1000,
382
+ };
383
+ }
384
+ }
@@ -0,0 +1,57 @@
1
+ import { consume } from "@lit/context";
2
+ import type { LitElement } from "lit";
3
+ import { apiHostContext } from "../gui/EFWorkbench";
4
+ import { state } from "lit/decorators/state.js";
5
+ import { Task } from "@lit/task";
6
+ import { property } from "lit/decorators/property.js";
7
+
8
+ export declare class EFSourceMixinInterface {
9
+ productionSrc(): string;
10
+ src: string;
11
+ }
12
+
13
+ interface EFSourceMixinOptions {
14
+ assetType: string;
15
+ }
16
+ type Constructor<T = {}> = new (...args: any[]) => T;
17
+ export function EFSourceMixin<T extends Constructor<LitElement>>(
18
+ superClass: T,
19
+ options: EFSourceMixinOptions,
20
+ ) {
21
+ class EFSourceElement extends superClass {
22
+ @consume({ context: apiHostContext, subscribe: true })
23
+ @state()
24
+ private efHost?: string;
25
+
26
+ @property({ type: String })
27
+ src = "";
28
+
29
+ productionSrc() {
30
+ if (!this.md5SumLoader.value) {
31
+ throw new Error(
32
+ `MD5 sum not available for ${this}. Cannot generate production URL`,
33
+ );
34
+ }
35
+
36
+ if (!this.efHost) {
37
+ throw new Error(
38
+ `efHost not available for ${this}. Cannot generate production URL`,
39
+ );
40
+ }
41
+
42
+ return `${this.efHost}/api/video2/${options.assetType}/${this.md5SumLoader.value}`;
43
+ }
44
+
45
+ md5SumLoader = new Task(this, {
46
+ autoRun: false,
47
+ args: () => [this.src] as const,
48
+ task: async ([src], { signal }) => {
49
+ const md5Path = `/@ef-asset/${src}`;
50
+ const response = await fetch(md5Path, { method: "HEAD", signal });
51
+ return response.headers.get("etag") ?? undefined;
52
+ },
53
+ });
54
+ }
55
+
56
+ return EFSourceElement as Constructor<EFSourceMixinInterface> & T;
57
+ }
@@ -0,0 +1,231 @@
1
+ import type { LitElement, ReactiveController } from "lit";
2
+ import { consume, createContext } from "@lit/context";
3
+ import { property, state } from "lit/decorators.js";
4
+ import type { EFTimegroup } from "./EFTimegroup";
5
+
6
+ import { durationConverter } from "./durationConverter";
7
+ import { Task } from "@lit/task";
8
+ import { EF_INTERACTIVE } from "../EF_INTERACTIVE";
9
+
10
+ export const timegroupContext = createContext<EFTimegroup>(
11
+ Symbol("timeGroupContext"),
12
+ );
13
+
14
+ export declare class TemporalMixinInterface {
15
+ get hasOwnDuration(): boolean;
16
+ get durationMs(): number;
17
+ get startTimeMs(): number;
18
+ get startTimeWithinParentMs(): number;
19
+ get endTimeMs(): number;
20
+ get ownCurrentTimeMs(): number;
21
+
22
+ parentTimegroup?: EFTimegroup;
23
+ rootTimegroup?: EFTimegroup;
24
+
25
+ frameTask: Task<readonly unknown[], unknown>;
26
+ }
27
+
28
+ export const isEFTemporal = (obj: any): obj is TemporalMixinInterface =>
29
+ obj[EF_TEMPORAL];
30
+
31
+ const EF_TEMPORAL = Symbol("EF_TEMPORAL");
32
+
33
+ export const deepGetTemporalElements = (
34
+ element: Element,
35
+ temporals: TemporalMixinInterface[] = [],
36
+ ) => {
37
+ for (const child of element.children) {
38
+ if (isEFTemporal(child)) {
39
+ temporals.push(child);
40
+ }
41
+ deepGetTemporalElements(child, temporals);
42
+ }
43
+ return temporals;
44
+ };
45
+
46
+ export const deepGetElementsWithFrameTasks = (
47
+ element: Element,
48
+ elements: Array<HTMLElement & { frameTask: Task }> = [],
49
+ ) => {
50
+ for (const child of element.children) {
51
+ if ("frameTask" in child && child.frameTask instanceof Task) {
52
+ elements.push(child as HTMLElement & { frameTask: Task });
53
+ }
54
+ deepGetElementsWithFrameTasks(child, elements);
55
+ }
56
+ return elements;
57
+ };
58
+
59
+ export const shallowGetTemporalElements = (
60
+ element: Element,
61
+ temporals: TemporalMixinInterface[] = [],
62
+ ) => {
63
+ for (const child of element.children) {
64
+ if (isEFTemporal(child)) {
65
+ temporals.push(child);
66
+ } else {
67
+ shallowGetTemporalElements(child, temporals);
68
+ }
69
+ }
70
+ return temporals;
71
+ };
72
+
73
+ export class OwnCurrentTimeController implements ReactiveController {
74
+ constructor(
75
+ private host: EFTimegroup,
76
+ private temporal: TemporalMixinInterface & LitElement,
77
+ ) {
78
+ host.addController(this);
79
+ }
80
+
81
+ hostUpdated() {
82
+ this.temporal.requestUpdate("ownCurrentTimeMs");
83
+ }
84
+
85
+ remove() {
86
+ this.host.removeController(this);
87
+ }
88
+ }
89
+
90
+ type Constructor<T = {}> = new (...args: any[]) => T;
91
+
92
+ export const EFTemporal = <T extends Constructor<LitElement>>(
93
+ superClass: T,
94
+ ) => {
95
+ class TemporalMixinClass extends superClass {
96
+ ownCurrentTimeController?: OwnCurrentTimeController;
97
+
98
+ #parentTimegroup?: EFTimegroup;
99
+ @consume({ context: timegroupContext, subscribe: true })
100
+ @property({ attribute: false })
101
+ set parentTimegroup(value: EFTimegroup | undefined) {
102
+ this.#parentTimegroup = value;
103
+ this.ownCurrentTimeController?.remove();
104
+ this.rootTimegroup = this.getRootTimegroup();
105
+ if (this.rootTimegroup) {
106
+ this.ownCurrentTimeController = new OwnCurrentTimeController(
107
+ this.rootTimegroup,
108
+ this,
109
+ );
110
+ }
111
+ }
112
+ get parentTimegroup() {
113
+ return this.#parentTimegroup;
114
+ }
115
+
116
+ @property({
117
+ type: String,
118
+ attribute: "offset",
119
+ converter: durationConverter,
120
+ })
121
+ private _offsetMs = 0;
122
+
123
+ @property({
124
+ type: Number,
125
+ attribute: "duration",
126
+ converter: durationConverter,
127
+ })
128
+ private _durationMs?: number;
129
+
130
+ @state()
131
+ rootTimegroup?: EFTimegroup = this.getRootTimegroup();
132
+
133
+ private getRootTimegroup(): EFTimegroup | undefined {
134
+ let parent =
135
+ this.tagName === "EF-TIMEGROUP" ? this : this.parentTimegroup;
136
+ while (parent?.parentTimegroup) {
137
+ parent = parent.parentTimegroup;
138
+ }
139
+ return parent as EFTimegroup | undefined;
140
+ }
141
+
142
+ get hasOwnDuration() {
143
+ return false;
144
+ }
145
+
146
+ // Defining this as a getter to a private property allows us to
147
+ // override it classes that include this mixin.
148
+ get durationMs() {
149
+ const durationMs =
150
+ this._durationMs || this.parentTimegroup?.durationMs || 0;
151
+ return durationMs || 0;
152
+ }
153
+
154
+ get offsetMs() {
155
+ return this._offsetMs || 0;
156
+ }
157
+
158
+ get parentTemporal() {
159
+ let parent = this.parentElement;
160
+ while (parent && !isEFTemporal(parent)) {
161
+ parent = parent.parentElement;
162
+ }
163
+ return parent;
164
+ }
165
+
166
+ get startTimeWithinParentMs() {
167
+ if (!this.parentTemporal) {
168
+ return 0;
169
+ }
170
+ return this.startTimeMs - this.parentTemporal.startTimeMs;
171
+ }
172
+
173
+ get startTimeMs(): number {
174
+ const parentTimegroup = this.parentTimegroup;
175
+ if (!parentTimegroup) {
176
+ return 0;
177
+ }
178
+ switch (parentTimegroup.mode) {
179
+ case "sequence": {
180
+ const siblingTemorals = shallowGetTemporalElements(parentTimegroup);
181
+ const ownIndex = siblingTemorals.indexOf(this);
182
+ if (ownIndex === 0) {
183
+ return parentTimegroup.startTimeMs;
184
+ }
185
+ const previous = siblingTemorals[ownIndex - 1];
186
+ if (!previous) {
187
+ throw new Error("Previous temporal element not found");
188
+ }
189
+ return previous.startTimeMs + previous.durationMs;
190
+ }
191
+ case "contain":
192
+ case "fixed":
193
+ return parentTimegroup.startTimeMs + this.offsetMs;
194
+ default:
195
+ throw new Error(`Invalid time mode: ${parentTimegroup.mode}`);
196
+ }
197
+ }
198
+
199
+ get endTimeMs(): number {
200
+ return this.startTimeMs + this.durationMs;
201
+ }
202
+
203
+ get ownCurrentTimeMs() {
204
+ if (this.rootTimegroup) {
205
+ return Math.min(
206
+ Math.max(0, this.rootTimegroup.currentTimeMs - this.startTimeMs),
207
+ this.durationMs,
208
+ );
209
+ }
210
+ return 0;
211
+ }
212
+
213
+ frameTask = new Task(this, {
214
+ autoRun: EF_INTERACTIVE,
215
+ args: () => [this.ownCurrentTimeMs] as const,
216
+ task: async ([], { signal: _signal }) => {
217
+ let fullyUpdated = await this.updateComplete;
218
+ while (!fullyUpdated) {
219
+ fullyUpdated = await this.updateComplete;
220
+ }
221
+ },
222
+ });
223
+ }
224
+
225
+ Object.defineProperty(TemporalMixinClass.prototype, EF_TEMPORAL, {
226
+ value: true,
227
+ });
228
+
229
+ return TemporalMixinClass as unknown as Constructor<TemporalMixinInterface> &
230
+ T;
231
+ };