@editframe/elements 0.14.0-beta.3 → 0.15.0-beta.1

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.
@@ -1,9 +1,11 @@
1
1
  import { EFAudio } from "./EFAudio.js";
2
+ import { CSSStyleObserver } from "@bramus/style-observer";
2
3
  import { Task } from "@lit/task";
3
4
  import { html, css, LitElement } from "lit";
4
5
  import { property, customElement } from "lit/decorators.js";
5
6
  import { createRef, ref } from "lit/directives/ref.js";
6
7
  import { EF_INTERACTIVE } from "../EF_INTERACTIVE.js";
8
+ import { EF_RENDERING } from "../EF_RENDERING.js";
7
9
  import { TWMixin } from "../gui/TWMixin.js";
8
10
  import { CrossUpdateController } from "./CrossUpdateController.js";
9
11
  import { EFTemporal } from "./EFTemporal.js";
@@ -23,69 +25,47 @@ let EFWaveform = class extends EFTemporal(TWMixin(LitElement)) {
23
25
  super(...arguments);
24
26
  this.canvasRef = createRef();
25
27
  this.ctx = null;
28
+ this.styleObserver = null;
26
29
  this.mode = "bars";
27
30
  this.color = "currentColor";
28
31
  this.targetSelector = "";
32
+ this.lineWidth = 4;
29
33
  this.frameTask = new Task(this, {
30
34
  autoRun: EF_INTERACTIVE,
31
- args: () => [this.targetElement.audioBufferTask.status],
35
+ args: () => [this.targetElement?.frequencyDataTask.status],
32
36
  task: async () => {
33
- await this.targetElement.audioBufferTask.taskComplete;
37
+ if (!this.targetElement) return;
38
+ await this.targetElement.frequencyDataTask.taskComplete;
34
39
  this.ctx ||= this.initCanvas();
35
40
  const ctx = this.ctx;
36
41
  if (!ctx) return;
37
- if (!this.targetElement.audioBufferTask.value) return;
38
- if (this.targetElement.trimAdjustedOwnCurrentTimeMs > 0) {
39
- const FRAME_DURATION_MS = 48e3 / 1e3;
40
- const FRAME_SMEAR_MS = FRAME_DURATION_MS * 1;
41
- const FRAME_SMEAR_S = FRAME_SMEAR_MS / 1e3;
42
- const audioContext = new OfflineAudioContext(2, 48e3 / 25, 48e3);
43
- const audioBufferSource = audioContext.createBufferSource();
44
- audioBufferSource.buffer = this.targetElement.audioBufferTask.value.buffer;
45
- const analyser = audioContext.createAnalyser();
46
- analyser.fftSize = 128 * 8;
47
- audioBufferSource.connect(analyser);
48
- const startTime = Math.max(
49
- 0,
50
- (this.targetElement.trimAdjustedOwnCurrentTimeMs - this.targetElement.audioBufferTask.value.startOffsetMs) / 1e3
51
- );
52
- audioBufferSource.start(0, startTime, FRAME_SMEAR_S);
53
- await audioContext.startRendering();
54
- const frameData = new Uint8Array(analyser.frequencyBinCount);
55
- analyser.getByteFrequencyData(frameData);
56
- const smoothedData = frameData.slice(0, frameData.length / 2);
57
- if (this.color === "currentColor") {
58
- const computedStyle = getComputedStyle(this);
59
- const currentColor = computedStyle.color;
60
- ctx.strokeStyle = currentColor;
61
- ctx.fillStyle = currentColor;
62
- }
63
- switch (this.mode) {
64
- case "bars":
65
- this.drawBars(ctx, smoothedData);
66
- break;
67
- case "bricks":
68
- this.drawBricks(ctx, smoothedData);
69
- break;
70
- case "curve":
71
- this.drawCurve(ctx, smoothedData);
72
- break;
73
- case "line":
74
- this.drawLine(ctx, smoothedData);
75
- break;
76
- case "pixel":
77
- this.drawPixel(ctx, smoothedData);
78
- break;
79
- case "wave":
80
- this.drawWave(ctx, smoothedData);
81
- break;
82
- case "roundBars":
83
- this.drawRoundBars(ctx, smoothedData);
84
- break;
85
- case "equalizer":
86
- this.drawEqualizer(ctx, smoothedData);
87
- break;
88
- }
42
+ const frequencyData = this.targetElement.frequencyDataTask.value;
43
+ if (!frequencyData) return;
44
+ if (this.color === "currentColor") {
45
+ const computedStyle = getComputedStyle(this);
46
+ const currentColor = computedStyle.color;
47
+ ctx.strokeStyle = currentColor;
48
+ ctx.fillStyle = currentColor;
49
+ }
50
+ switch (this.mode) {
51
+ case "bars":
52
+ this.drawBars(ctx, frequencyData);
53
+ break;
54
+ case "bricks":
55
+ this.drawBricks(ctx, frequencyData);
56
+ break;
57
+ case "line":
58
+ this.drawLine(ctx, frequencyData);
59
+ break;
60
+ case "pixel":
61
+ this.drawPixel(ctx, frequencyData);
62
+ break;
63
+ case "wave":
64
+ this.drawWave(ctx, frequencyData);
65
+ break;
66
+ case "roundBars":
67
+ this.drawRoundBars(ctx, frequencyData);
68
+ break;
89
69
  }
90
70
  }
91
71
  });
@@ -98,8 +78,11 @@ let EFWaveform = class extends EFTemporal(TWMixin(LitElement)) {
98
78
  }
99
79
  connectedCallback() {
100
80
  super.connectedCallback();
101
- if (this.targetElement) {
102
- new CrossUpdateController(this.targetElement, this);
81
+ try {
82
+ if (this.targetElement) {
83
+ new CrossUpdateController(this.targetElement, this);
84
+ }
85
+ } catch (e) {
103
86
  }
104
87
  this.resizeObserver = new ResizeObserver(() => {
105
88
  this.resizeCanvas();
@@ -113,11 +96,18 @@ let EFWaveform = class extends EFTemporal(TWMixin(LitElement)) {
113
96
  }
114
97
  });
115
98
  this.mutationObserver.observe(this, { attributes: true });
99
+ if (!EF_RENDERING()) {
100
+ this.styleObserver = new CSSStyleObserver(["color"], () => {
101
+ this.frameTask.run();
102
+ });
103
+ this.styleObserver.attach(this);
104
+ }
116
105
  }
117
106
  disconnectedCallback() {
118
107
  super.disconnectedCallback();
119
108
  this.resizeObserver?.disconnect();
120
109
  this.mutationObserver?.disconnect();
110
+ this.styleObserver?.detach();
121
111
  }
122
112
  resizeCanvas() {
123
113
  this.ctx = this.initCanvas();
@@ -128,7 +118,10 @@ let EFWaveform = class extends EFTemporal(TWMixin(LitElement)) {
128
118
  initCanvas() {
129
119
  const canvas = this.canvasRef.value;
130
120
  if (!canvas) return null;
131
- const rect = this.getBoundingClientRect();
121
+ const rect = {
122
+ width: this.offsetWidth,
123
+ height: this.offsetHeight
124
+ };
132
125
  const dpr = window.devicePixelRatio;
133
126
  canvas.style.width = `${rect.width}px`;
134
127
  canvas.style.height = `${rect.height}px`;
@@ -142,149 +135,161 @@ let EFWaveform = class extends EFTemporal(TWMixin(LitElement)) {
142
135
  }
143
136
  drawBars(ctx, frequencyData) {
144
137
  const canvas = ctx.canvas;
145
- const waveWidth = canvas.width / devicePixelRatio;
146
- const waveHeight = canvas.height / devicePixelRatio;
147
- const barWidth = waveWidth / frequencyData.length * 0.8;
148
- const baseline = waveHeight / 2;
138
+ const waveWidth = canvas.width;
139
+ const waveHeight = canvas.height;
140
+ const baseline = waveHeight / 4;
141
+ const totalBars = frequencyData.length;
142
+ const paddingInner = 0.5;
143
+ const paddingOuter = 0.01;
144
+ const availableWidth = waveWidth * (1 - 2 * paddingOuter);
145
+ const barWidth = availableWidth / (totalBars + (totalBars - 1) * paddingInner);
149
146
  ctx.clearRect(0, 0, waveWidth, waveHeight);
147
+ const path = new Path2D();
150
148
  frequencyData.forEach((value, i) => {
151
- const height = value / 255 * (waveHeight / 2);
152
- const x = i * (waveWidth / frequencyData.length);
149
+ const normalizedValue = value / 255;
150
+ const height = normalizedValue * (waveHeight / 2);
151
+ const x = waveWidth * paddingOuter + i * (barWidth * (1 + paddingInner));
153
152
  const y = baseline - height;
154
- ctx.fillRect(x, y, barWidth, Math.max(height * 2, 2));
153
+ path.rect(x, y, barWidth, height * 2);
155
154
  });
155
+ ctx.fill(path);
156
156
  }
157
157
  drawBricks(ctx, frequencyData) {
158
158
  const canvas = ctx.canvas;
159
- const waveWidth = canvas.width / devicePixelRatio;
160
- const waveHeight = canvas.height / devicePixelRatio;
161
- const brickWidth = waveWidth / frequencyData.length;
162
- const brickHeightFactor = waveHeight / 255 / 2;
163
- const brickPadding = 2;
164
- const midHeight = waveHeight / 2;
165
- ctx.clearRect(0, 0, waveWidth, waveHeight);
166
- ctx.beginPath();
167
- ctx.setLineDash([2]);
168
- ctx.lineWidth = 4;
169
- ctx.moveTo(0, midHeight);
170
- ctx.lineTo(waveWidth, midHeight);
171
- ctx.stroke();
172
- ctx.setLineDash([]);
173
- frequencyData.forEach((value, i) => {
174
- const x = i * brickWidth;
175
- const height = value * brickHeightFactor * 2;
176
- const y = midHeight - height / 2;
177
- ctx.fillRect(x, y, brickWidth - brickPadding, height);
178
- });
179
- }
180
- drawLine(ctx, frequencyData) {
181
- const canvas = ctx.canvas;
182
- const waveWidth = canvas.width / devicePixelRatio;
183
- const waveHeight = canvas.height / devicePixelRatio;
159
+ const waveWidth = canvas.width;
160
+ const waveHeight = canvas.height;
184
161
  ctx.clearRect(0, 0, waveWidth, waveHeight);
185
- ctx.beginPath();
162
+ const path = new Path2D();
163
+ const columnWidth = waveWidth / frequencyData.length;
164
+ const boxSize = columnWidth * 0.9;
186
165
  frequencyData.forEach((value, i) => {
187
- const x = i / frequencyData.length * waveWidth;
188
- const y = (1 - value / 255) * waveHeight;
189
- if (i === 0) {
190
- ctx.moveTo(x, y);
191
- } else {
192
- ctx.lineTo(x, y);
166
+ const brickHeight = value / 255 * waveHeight;
167
+ for (let j = 0; j <= brickHeight; j++) {
168
+ const x = columnWidth * i;
169
+ const y = waveHeight - (j * columnWidth + boxSize);
170
+ path.rect(x, y, boxSize, boxSize);
193
171
  }
194
172
  });
195
- ctx.lineWidth = 4;
196
- ctx.stroke();
173
+ ctx.fill(path);
197
174
  }
198
175
  drawRoundBars(ctx, frequencyData) {
199
176
  const canvas = ctx.canvas;
200
- const waveWidth = canvas.width / devicePixelRatio;
201
- const waveHeight = canvas.height / devicePixelRatio;
202
- const barWidth = waveWidth / frequencyData.length * 0.5;
203
- const baseline = waveHeight / 2;
204
- const maxHeight = waveHeight / 2;
177
+ const waveWidth = canvas.width;
178
+ const waveHeight = canvas.height;
179
+ const baseline = waveHeight / 4;
180
+ const totalBars = frequencyData.length;
181
+ const paddingInner = 0.5;
182
+ const paddingOuter = 0.01;
183
+ const availableWidth = waveWidth * (1 - 2 * paddingOuter);
184
+ const barWidth = availableWidth / (totalBars + (totalBars - 1) * paddingInner);
205
185
  ctx.clearRect(0, 0, waveWidth, waveHeight);
186
+ const path = new Path2D();
206
187
  frequencyData.forEach((value, i) => {
207
- const height = value / 255 * maxHeight;
208
- const x = i * (waveWidth / frequencyData.length);
188
+ const normalizedValue = value / 255;
189
+ const height = normalizedValue * (waveHeight / 2);
190
+ const x = waveWidth * paddingOuter + i * (barWidth * (1 + paddingInner));
209
191
  const y = baseline - height;
210
- const radius = barWidth / 2;
211
- ctx.beginPath();
212
- ctx.roundRect(x, y, barWidth, Math.max(height * 2, 2), radius);
213
- ctx.fill();
192
+ path.roundRect(x, y, barWidth, height * 2, barWidth / 2);
214
193
  });
194
+ ctx.fill(path);
215
195
  }
216
196
  drawEqualizer(ctx, frequencyData) {
217
197
  const canvas = ctx.canvas;
218
- const waveWidth = canvas.width / devicePixelRatio;
219
- const waveHeight = canvas.height / devicePixelRatio;
198
+ const waveWidth = canvas.width;
199
+ const waveHeight = canvas.height / 2;
220
200
  const barWidth = waveWidth / frequencyData.length * 0.8;
221
201
  const baseline = waveHeight / 2;
222
202
  ctx.clearRect(0, 0, waveWidth, waveHeight);
223
- ctx.beginPath();
224
- ctx.lineWidth = 2;
225
- ctx.moveTo(0, baseline);
226
- ctx.lineTo(waveWidth, baseline);
227
- ctx.stroke();
203
+ const baselinePath = new Path2D();
204
+ const barsPath = new Path2D();
205
+ baselinePath.moveTo(0, baseline);
206
+ baselinePath.lineTo(waveWidth, baseline);
228
207
  frequencyData.forEach((value, i) => {
229
208
  const height = value / 255 * (waveHeight / 2);
230
209
  const x = i * (waveWidth / frequencyData.length);
231
210
  const y = baseline - height;
232
- ctx.fillRect(x, y, barWidth, Math.max(height * 2, 1));
211
+ barsPath.rect(x, y, barWidth, Math.max(height * 2, 1));
233
212
  });
213
+ ctx.lineWidth = 2;
214
+ ctx.stroke(baselinePath);
215
+ ctx.fill(barsPath);
234
216
  }
235
- drawCurve(ctx, frequencyData) {
217
+ drawLine(ctx, frequencyData) {
236
218
  const canvas = ctx.canvas;
237
- const waveWidth = canvas.width / devicePixelRatio;
238
- const waveHeight = canvas.height / devicePixelRatio;
219
+ const waveWidth = canvas.width;
220
+ const waveHeight = canvas.height / 2;
239
221
  ctx.clearRect(0, 0, waveWidth, waveHeight);
240
- ctx.beginPath();
222
+ const path = new Path2D();
241
223
  frequencyData.forEach((value, i) => {
242
224
  const x = i / frequencyData.length * waveWidth;
243
225
  const y = (1 - value / 255) * waveHeight;
244
226
  if (i === 0) {
245
- ctx.moveTo(x, y);
227
+ path.moveTo(x, y);
246
228
  } else {
247
- ctx.lineTo(x, y);
229
+ path.lineTo(x, y);
248
230
  }
249
231
  });
250
- ctx.lineWidth = 4;
251
- ctx.stroke();
232
+ ctx.lineWidth = this.lineWidth;
233
+ ctx.stroke(path);
252
234
  }
253
235
  drawPixel(ctx, frequencyData) {
254
236
  const canvas = ctx.canvas;
255
- const waveWidth = canvas.width / devicePixelRatio;
256
- const waveHeight = canvas.height / devicePixelRatio;
237
+ const waveWidth = canvas.width;
238
+ const waveHeight = canvas.height / 2;
257
239
  const baseline = waveHeight / 2;
258
- const barWidth = waveWidth / frequencyData.length * 0.97;
240
+ const barWidth = waveWidth / frequencyData.length;
259
241
  ctx.clearRect(0, 0, waveWidth, waveHeight);
242
+ const path = new Path2D();
260
243
  frequencyData.forEach((value, i) => {
261
244
  const x = i * (waveWidth / frequencyData.length);
262
245
  const barHeight = value / 255 * baseline;
263
246
  const y = baseline - barHeight;
264
- ctx.fillRect(x, y, barWidth, barHeight * 2);
247
+ path.rect(x, y, barWidth, barHeight * 2);
265
248
  });
249
+ ctx.fill(path);
266
250
  }
267
251
  drawWave(ctx, frequencyData) {
268
252
  const canvas = ctx.canvas;
269
- const waveWidth = canvas.width / devicePixelRatio;
270
- const waveHeight = canvas.height / devicePixelRatio;
271
- const baseline = waveHeight / 2;
272
- const barWidth = waveWidth / frequencyData.length * 0.97;
253
+ const waveWidth = canvas.width;
254
+ const waveHeight = canvas.height;
255
+ const baseline = canvas.height / 4;
273
256
  ctx.clearRect(0, 0, waveWidth, waveHeight);
274
- ctx.beginPath();
275
- ctx.moveTo(0, baseline);
276
- ctx.lineTo(waveWidth, baseline);
277
- ctx.strokeStyle = this.color;
278
- ctx.lineWidth = 1;
279
- ctx.stroke();
257
+ const path = new Path2D();
258
+ path.moveTo(0, baseline);
280
259
  frequencyData.forEach((value, i) => {
281
- const x = i * (waveWidth / frequencyData.length);
282
- const barHeight = value / 255 * (waveHeight / 2);
283
- const y = baseline - barHeight;
284
- ctx.fillRect(x, y, barWidth, barHeight * 2);
260
+ const normalizedValue = value / 255;
261
+ const x = i / (frequencyData.length - 1) * waveWidth;
262
+ const y = baseline - normalizedValue * (waveHeight / 2);
263
+ if (i === 0) {
264
+ path.moveTo(x, y);
265
+ } else {
266
+ const prevX = (i - 1) / (frequencyData.length - 1) * waveWidth;
267
+ const prevValue = (frequencyData[i - 1] ?? 0) / 255;
268
+ const prevY = baseline - prevValue * (waveHeight / 2);
269
+ const xc = (prevX + x) / 2;
270
+ const yc = (prevY + y) / 2;
271
+ path.quadraticCurveTo(prevX, prevY, xc, yc);
272
+ }
285
273
  });
274
+ for (let i = frequencyData.length - 1; i >= 0; i--) {
275
+ const normalizedValue = (frequencyData[i] ?? 0) / 255;
276
+ const x = i / (frequencyData.length - 1) * waveWidth;
277
+ const y = baseline + normalizedValue * (waveHeight / 2);
278
+ if (i === frequencyData.length - 1) {
279
+ path.lineTo(x, y);
280
+ } else {
281
+ const nextX = (i + 1) / (frequencyData.length - 1) * waveWidth;
282
+ const nextValue = (frequencyData[i + 1] ?? 0) / 255;
283
+ const nextY = baseline + nextValue * (waveHeight / 2);
284
+ const xc = (nextX + x) / 2;
285
+ const yc = (nextY + y) / 2;
286
+ path.quadraticCurveTo(nextX, nextY, xc, yc);
287
+ }
288
+ }
289
+ ctx.fill(path);
286
290
  }
287
291
  get durationMs() {
292
+ if (!this.targetElement) return 0;
288
293
  return this.targetElement.durationMs;
289
294
  }
290
295
  get targetElement() {
@@ -292,7 +297,7 @@ let EFWaveform = class extends EFTemporal(TWMixin(LitElement)) {
292
297
  if (target instanceof EFAudio || target instanceof EFVideo) {
293
298
  return target;
294
299
  }
295
- throw new Error("Invalid target, must be an EFAudio or EFVideo element");
300
+ return null;
296
301
  }
297
302
  updated(changedProperties) {
298
303
  super.updated(changedProperties);
@@ -328,6 +333,9 @@ __decorateClass([
328
333
  __decorateClass([
329
334
  property({ type: String, attribute: "target", reflect: true })
330
335
  ], EFWaveform.prototype, "targetSelector", 2);
336
+ __decorateClass([
337
+ property({ type: Number, attribute: "line-width" })
338
+ ], EFWaveform.prototype, "lineWidth", 2);
331
339
  EFWaveform = __decorateClass([
332
340
  customElement("ef-waveform")
333
341
  ], EFWaveform);
@@ -62,7 +62,7 @@ let EFWorkbench = class extends ContextMixin(TWMixin(LitElement)) {
62
62
  focusOverlay.style.display = "block";
63
63
  const rect = this.focusedElement.getBoundingClientRect();
64
64
  Object.assign(focusOverlay.style, {
65
- position: "absolute",
65
+ position: "fixed",
66
66
  top: `${rect.top}px`,
67
67
  left: `${rect.left}px`,
68
68
  width: `${rect.width}px`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@editframe/elements",
3
- "version": "0.14.0-beta.3",
3
+ "version": "0.15.0-beta.1",
4
4
  "description": "",
5
5
  "exports": {
6
6
  ".": {
@@ -21,7 +21,8 @@
21
21
  "author": "",
22
22
  "license": "UNLICENSED",
23
23
  "dependencies": {
24
- "@editframe/assets": "0.14.0-beta.3",
24
+ "@bramus/style-observer": "^1.3.0",
25
+ "@editframe/assets": "0.15.0-beta.1",
25
26
  "@lit/context": "^1.1.2",
26
27
  "@lit/task": "^1.0.1",
27
28
  "d3": "^7.9.0",
@@ -18,9 +18,9 @@ describe("EFImage", () => {
18
18
  const workbench = document.createElement("ef-workbench");
19
19
  const element = document.createElement("ef-image");
20
20
  workbench.appendChild(element);
21
- element.assetId = "550e8400-e29b-41d4-a716-446655440000:example.jpg";
21
+ element.assetId = "550e8400-e29b-41d4-a716-446655440000";
22
22
  expect(element.assetPath()).toBe(
23
- "editframe://api/v1/image_files/550e8400-e29b-41d4-a716-446655440000:example.jpg",
23
+ "editframe://api/v1/image_files/550e8400-e29b-41d4-a716-446655440000",
24
24
  );
25
25
  });
26
26
  });
@@ -46,4 +46,35 @@ describe("EFImage", () => {
46
46
  expect(image.assetPath()).toBe(`test:///api/v1/image_files/${id}`);
47
47
  });
48
48
  });
49
+
50
+ describe("hasOwnDuration", () => {
51
+ test("is false by default", () => {
52
+ const image = document.createElement("ef-image");
53
+ expect(image.hasOwnDuration).toBe(false);
54
+ });
55
+
56
+ test("is true when duration is set", () => {
57
+ const image = document.createElement("ef-image");
58
+ image.setAttribute("duration", "1s");
59
+ expect(image.hasOwnDuration).toBe(true);
60
+ });
61
+ });
62
+
63
+ describe("durationMs", () => {
64
+ test("Can be set on element directly", () => {
65
+ const image = document.createElement("ef-image");
66
+ image.src =
67
+ "https://editframe.dev/api/v1/image_files/550e8400-e29b-41d4-a716-446655440000";
68
+ image.duration = "1s";
69
+ expect(image.durationMs).toBe(1000);
70
+ });
71
+
72
+ test("Can be set through setAttribute", () => {
73
+ const image = document.createElement("ef-image");
74
+ image.src =
75
+ "https://editframe.dev/api/v1/image_files/550e8400-e29b-41d4-a716-446655440000";
76
+ image.setAttribute("duration", "1s");
77
+ expect(image.durationMs).toBe(1000);
78
+ });
79
+ });
49
80
  });
@@ -5,12 +5,15 @@ import { createRef, ref } from "lit/directives/ref.js";
5
5
  import { EF_INTERACTIVE } from "../EF_INTERACTIVE.js";
6
6
  import { EF_RENDERING } from "../EF_RENDERING.js";
7
7
  import { EFSourceMixin } from "./EFSourceMixin.js";
8
+ import { EFTemporal } from "./EFTemporal.js";
8
9
  import { FetchMixin } from "./FetchMixin.js";
9
10
 
10
11
  @customElement("ef-image")
11
- export class EFImage extends EFSourceMixin(FetchMixin(LitElement), {
12
- assetType: "image_files",
13
- }) {
12
+ export class EFImage extends EFTemporal(
13
+ EFSourceMixin(FetchMixin(LitElement), {
14
+ assetType: "image_files",
15
+ }),
16
+ ) {
14
17
  static styles = [
15
18
  css`
16
19
  :host {
@@ -52,6 +55,10 @@ export class EFImage extends EFSourceMixin(FetchMixin(LitElement), {
52
55
  return `/@ef-image/${this.src}`;
53
56
  }
54
57
 
58
+ get hasOwnDuration() {
59
+ return this.hasExplicitDuration;
60
+ }
61
+
55
62
  fetchImage = new Task(this, {
56
63
  autoRun: EF_INTERACTIVE,
57
64
  args: () => [this.assetPath(), this.fetch] as const,