@framv/video 0.1.0
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 +78 -0
- package/dist/bundle.esm.js +450 -0
- package/dist/bundle.iife.js +18942 -0
- package/dist/cdn.d.ts +4 -0
- package/dist/cdn.d.ts.map +1 -0
- package/dist/cdn.js +4 -0
- package/dist/cdn.js.map +1 -0
- package/dist/element.d.ts +49 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +283 -0
- package/dist/element.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/player.d.ts +50 -0
- package/dist/player.d.ts.map +1 -0
- package/dist/player.js +231 -0
- package/dist/player.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Mens Reversa
|
|
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,78 @@
|
|
|
1
|
+
# framv / video
|
|
2
|
+
|
|
3
|
+
Plain HTML video compositions for [framv](https://github.com/mensreversa/framv).
|
|
4
|
+
|
|
5
|
+
No bundler. No framework. Write sequences as HTML fragments, load the framv engine, and your page becomes a renderable video.
|
|
6
|
+
|
|
7
|
+
## Structure
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
src/
|
|
11
|
+
index.html # video index
|
|
12
|
+
intro/
|
|
13
|
+
index.html # player shell + framv engine
|
|
14
|
+
sequences/
|
|
15
|
+
00-hero.html
|
|
16
|
+
01-features.html
|
|
17
|
+
02-code.html
|
|
18
|
+
03-cta.html
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install
|
|
25
|
+
npm run dev
|
|
26
|
+
# → http://localhost:3000
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Open a video, press **Space** to play, arrow keys to step frame by frame, or drag the scrubber.
|
|
30
|
+
|
|
31
|
+
## Writing a sequence
|
|
32
|
+
|
|
33
|
+
Each file in `sequences/` is a plain HTML fragment. The engine fetches it, injects it into the canvas, and adds `.framv-active` while the sequence is on screen.
|
|
34
|
+
|
|
35
|
+
Use `animation-play-state: paused` in CSS and flip it to `running` on `.framv-active` so animations always restart cleanly:
|
|
36
|
+
|
|
37
|
+
```html
|
|
38
|
+
<style>
|
|
39
|
+
.my-element {
|
|
40
|
+
opacity: 0;
|
|
41
|
+
animation: rise 0.5s forwards;
|
|
42
|
+
animation-play-state: paused;
|
|
43
|
+
}
|
|
44
|
+
.framv-active .my-element {
|
|
45
|
+
animation-play-state: running;
|
|
46
|
+
}
|
|
47
|
+
@keyframes rise {
|
|
48
|
+
to {
|
|
49
|
+
opacity: 1;
|
|
50
|
+
transform: translateY(0);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
</style>
|
|
54
|
+
|
|
55
|
+
<div class="absolute inset-0 bg-black">
|
|
56
|
+
<h1 class="my-element">Hello framv</h1>
|
|
57
|
+
</div>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Register it in `intro/index.html`:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
const SEQUENCES = [{ src: "./sequences/00-my-sequence.html", from: 0, duration: 90 }];
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
window.framv.setFrame(90); // jump to frame 90
|
|
70
|
+
window.framv.play();
|
|
71
|
+
window.framv.pause();
|
|
72
|
+
window.framv.frame; // current frame
|
|
73
|
+
window.framv.fps; // 30
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
// src/element.ts
|
|
2
|
+
import { exportElement } from "@framv/core";
|
|
3
|
+
|
|
4
|
+
// src/player.ts
|
|
5
|
+
var Player = class {
|
|
6
|
+
/** The root element whose animations this player controls. Can be any HTML element (div, section, etc.) or SVGSVGElement. */
|
|
7
|
+
_element;
|
|
8
|
+
_currentTime = 0;
|
|
9
|
+
_duration = 0;
|
|
10
|
+
_playing = false;
|
|
11
|
+
_rafId = null;
|
|
12
|
+
_lastRafTime = null;
|
|
13
|
+
_listeners = /* @__PURE__ */ new Map();
|
|
14
|
+
constructor(element) {
|
|
15
|
+
this._element = element;
|
|
16
|
+
}
|
|
17
|
+
// ─── State ───────────────────────────────────────────────────────────────
|
|
18
|
+
get currentTime() {
|
|
19
|
+
return this._currentTime;
|
|
20
|
+
}
|
|
21
|
+
get duration() {
|
|
22
|
+
return this._duration;
|
|
23
|
+
}
|
|
24
|
+
get playing() {
|
|
25
|
+
return this._playing;
|
|
26
|
+
}
|
|
27
|
+
setDuration(duration) {
|
|
28
|
+
if (duration >= 0) {
|
|
29
|
+
this._duration = duration;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ─── Controls ────────────────────────────────────────────────────────────
|
|
33
|
+
async play() {
|
|
34
|
+
if (this._playing) return;
|
|
35
|
+
this._playing = true;
|
|
36
|
+
await this._playElement(this._element);
|
|
37
|
+
this._startRafLoop();
|
|
38
|
+
this._emit("play");
|
|
39
|
+
}
|
|
40
|
+
pause() {
|
|
41
|
+
if (!this._playing) return;
|
|
42
|
+
this._playing = false;
|
|
43
|
+
this._stopRafLoop();
|
|
44
|
+
this._pauseElement(this._element);
|
|
45
|
+
this._emit("pause");
|
|
46
|
+
}
|
|
47
|
+
async seek(time) {
|
|
48
|
+
const clamped = this._duration > 0 ? Math.max(0, Math.min(time, this._duration)) : Math.max(0, time);
|
|
49
|
+
this._currentTime = clamped;
|
|
50
|
+
await this._seekElement(this._element, clamped);
|
|
51
|
+
this._emit("seek", clamped);
|
|
52
|
+
this._emit("timeupdate", clamped);
|
|
53
|
+
}
|
|
54
|
+
destroy() {
|
|
55
|
+
this.pause();
|
|
56
|
+
this._listeners.clear();
|
|
57
|
+
}
|
|
58
|
+
// ─── Events ──────────────────────────────────────────────────────────────
|
|
59
|
+
on(event, cb) {
|
|
60
|
+
if (!this._listeners.has(event)) {
|
|
61
|
+
this._listeners.set(event, /* @__PURE__ */ new Set());
|
|
62
|
+
}
|
|
63
|
+
this._listeners.get(event).add(cb);
|
|
64
|
+
return () => {
|
|
65
|
+
this._listeners.get(event)?.delete(cb);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// ─── Internal: rAF loop ──────────────────────────────────────────────────
|
|
69
|
+
_startRafLoop() {
|
|
70
|
+
this._lastRafTime = null;
|
|
71
|
+
const tick = (now) => {
|
|
72
|
+
if (!this._playing) return;
|
|
73
|
+
if (this._lastRafTime !== null) {
|
|
74
|
+
const delta = (now - this._lastRafTime) / 1e3;
|
|
75
|
+
this._currentTime += delta;
|
|
76
|
+
if (this._duration > 0 && this._currentTime >= this._duration) {
|
|
77
|
+
this._currentTime = this._duration;
|
|
78
|
+
this._emit("timeupdate", this._currentTime);
|
|
79
|
+
this._playing = false;
|
|
80
|
+
this._stopRafLoop();
|
|
81
|
+
this._pauseElement(this._element);
|
|
82
|
+
this._emit("ended");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
this._lastRafTime = now;
|
|
87
|
+
const all = [this._element, ...Array.from(this._element.querySelectorAll("*"))];
|
|
88
|
+
for (const el of all) {
|
|
89
|
+
if (typeof el.draw === "function") el.draw(this._currentTime);
|
|
90
|
+
}
|
|
91
|
+
this._emit("timeupdate", this._currentTime);
|
|
92
|
+
this._rafId = requestAnimationFrame(tick);
|
|
93
|
+
};
|
|
94
|
+
this._rafId = requestAnimationFrame(tick);
|
|
95
|
+
}
|
|
96
|
+
_stopRafLoop() {
|
|
97
|
+
if (this._rafId !== null) {
|
|
98
|
+
cancelAnimationFrame(this._rafId);
|
|
99
|
+
this._rafId = null;
|
|
100
|
+
}
|
|
101
|
+
this._lastRafTime = null;
|
|
102
|
+
}
|
|
103
|
+
// ─── Internal: element control ───────────────────────────────────────────
|
|
104
|
+
async _playElement(element) {
|
|
105
|
+
const all = [element, ...Array.from(element.querySelectorAll("*"))];
|
|
106
|
+
for (const el of all) {
|
|
107
|
+
if (el instanceof SVGSVGElement) {
|
|
108
|
+
el.unpauseAnimations();
|
|
109
|
+
} else if (el instanceof HTMLMediaElement) {
|
|
110
|
+
await this._playMedia(el);
|
|
111
|
+
} else if (el.getAnimations?.().length > 0) {
|
|
112
|
+
el.getAnimations().forEach((a) => a.play());
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
_pauseElement(element) {
|
|
117
|
+
const all = [element, ...Array.from(element.querySelectorAll("*"))];
|
|
118
|
+
for (const el of all) {
|
|
119
|
+
if (el instanceof SVGSVGElement) {
|
|
120
|
+
el.pauseAnimations();
|
|
121
|
+
} else if (el instanceof HTMLMediaElement) {
|
|
122
|
+
el.pause();
|
|
123
|
+
const extEl = el;
|
|
124
|
+
if (extEl._autoplayTimeout) {
|
|
125
|
+
clearTimeout(extEl._autoplayTimeout);
|
|
126
|
+
delete extEl._autoplayTimeout;
|
|
127
|
+
}
|
|
128
|
+
} else if (el.getAnimations?.().length > 0) {
|
|
129
|
+
el.getAnimations().forEach((a) => a.pause());
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async _seekElement(element, time) {
|
|
134
|
+
const all = [element, ...Array.from(element.querySelectorAll("*"))];
|
|
135
|
+
const promises = [];
|
|
136
|
+
for (const el of all) {
|
|
137
|
+
if (el instanceof SVGSVGElement) {
|
|
138
|
+
el.setCurrentTime(time);
|
|
139
|
+
} else if (el instanceof HTMLMediaElement) {
|
|
140
|
+
promises.push(this._seekMedia(el, time));
|
|
141
|
+
} else if (el.getAnimations?.().length > 0) {
|
|
142
|
+
el.getAnimations().forEach((a) => {
|
|
143
|
+
a.currentTime = time * 1e3;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
await Promise.all(promises);
|
|
148
|
+
}
|
|
149
|
+
async _playMedia(el) {
|
|
150
|
+
const startTime = this._getMediaStartTime(el);
|
|
151
|
+
const now = this._currentTime;
|
|
152
|
+
if (startTime > 0) {
|
|
153
|
+
if (now >= startTime) {
|
|
154
|
+
await this._seekMedia(el, now);
|
|
155
|
+
await el.play().catch(() => void 0);
|
|
156
|
+
} else {
|
|
157
|
+
const extEl = el;
|
|
158
|
+
extEl._autoplayTimeout = setTimeout(
|
|
159
|
+
async () => {
|
|
160
|
+
await el.play().catch(() => void 0);
|
|
161
|
+
},
|
|
162
|
+
(startTime - now) * 1e3
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
} else if (startTime < 0) {
|
|
166
|
+
await this._seekMedia(el, now);
|
|
167
|
+
await el.play().catch(() => void 0);
|
|
168
|
+
} else {
|
|
169
|
+
await el.play().catch(() => void 0);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
_seekMedia(el, containerTime) {
|
|
173
|
+
return new Promise((resolve) => {
|
|
174
|
+
const startTime = this._getMediaStartTime(el);
|
|
175
|
+
let adjusted;
|
|
176
|
+
if (startTime > 0) {
|
|
177
|
+
adjusted = containerTime >= startTime ? containerTime - startTime : 0;
|
|
178
|
+
} else if (startTime < 0) {
|
|
179
|
+
adjusted = containerTime + Math.abs(startTime);
|
|
180
|
+
} else {
|
|
181
|
+
adjusted = containerTime;
|
|
182
|
+
}
|
|
183
|
+
const target = el.loop && el.duration > 0 ? adjusted % el.duration : Math.min(adjusted, el.duration || 0);
|
|
184
|
+
const onSeeked = () => {
|
|
185
|
+
el.removeEventListener("seeked", onSeeked);
|
|
186
|
+
resolve();
|
|
187
|
+
};
|
|
188
|
+
el.addEventListener("seeked", onSeeked);
|
|
189
|
+
el.currentTime = target;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
_getMediaStartTime(el) {
|
|
193
|
+
const attr = el.getAttribute("data-media-start");
|
|
194
|
+
return attr ? parseFloat(attr) : 0;
|
|
195
|
+
}
|
|
196
|
+
// ─── Internal: emit ──────────────────────────────────────────────────────
|
|
197
|
+
_emit(event, time) {
|
|
198
|
+
this._listeners.get(event)?.forEach((cb) => cb(time));
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// src/element.ts
|
|
203
|
+
var STYLES = `
|
|
204
|
+
:host { display: block; position: relative; overflow: hidden; }
|
|
205
|
+
.framv-stage {
|
|
206
|
+
position: relative;
|
|
207
|
+
width: var(--framv-w, 1920px);
|
|
208
|
+
height: var(--framv-h, 1080px);
|
|
209
|
+
transform-origin: top left;
|
|
210
|
+
overflow: hidden;
|
|
211
|
+
}
|
|
212
|
+
.framv-toolbar {
|
|
213
|
+
display: flex; align-items: center; gap: 10px;
|
|
214
|
+
padding: 8px 12px;
|
|
215
|
+
background: rgba(0,0,0,0.7); backdrop-filter: blur(6px);
|
|
216
|
+
color: #fff; font: 13px/1 system-ui, monospace;
|
|
217
|
+
position: absolute; bottom: 0; left: 0; right: 0;
|
|
218
|
+
opacity: 0; transition: opacity 0.2s;
|
|
219
|
+
z-index: 10;
|
|
220
|
+
}
|
|
221
|
+
:host(:hover) .framv-toolbar,
|
|
222
|
+
:host([controls]) .framv-toolbar { opacity: 1; }
|
|
223
|
+
.framv-toolbar button {
|
|
224
|
+
background: none; border: 1px solid rgba(255,255,255,0.2);
|
|
225
|
+
color: inherit; cursor: pointer;
|
|
226
|
+
padding: 4px 10px; font: inherit; border-radius: 4px;
|
|
227
|
+
}
|
|
228
|
+
.framv-toolbar button:hover { background: rgba(255,255,255,0.15); }
|
|
229
|
+
.framv-toolbar .btn-export {
|
|
230
|
+
border-color: #ff79c6; color: #ff79c6;
|
|
231
|
+
}
|
|
232
|
+
input[type=range] {
|
|
233
|
+
flex: 1; accent-color: #ff79c6; cursor: pointer; height: 3px;
|
|
234
|
+
}
|
|
235
|
+
.framv-time { white-space: nowrap; font-variant-numeric: tabular-nums; opacity: 0.8; min-width: 100px; text-align: center; }
|
|
236
|
+
.framv-badge {
|
|
237
|
+
position: absolute; top: 8px; right: 8px;
|
|
238
|
+
background: rgba(0,0,0,0.6); color: #fff;
|
|
239
|
+
padding: 2px 8px; border-radius: 4px;
|
|
240
|
+
font: 11px system-ui; letter-spacing: 0.5px;
|
|
241
|
+
text-transform: uppercase; z-index: 5;
|
|
242
|
+
}
|
|
243
|
+
.framv-exporting-overlay {
|
|
244
|
+
position: absolute; inset: 0;
|
|
245
|
+
background: rgba(0,0,0,0.7);
|
|
246
|
+
display: flex; align-items: center; justify-content: center;
|
|
247
|
+
color: #fff; font: 16px system-ui; z-index: 20;
|
|
248
|
+
}
|
|
249
|
+
.framv-exporting-overlay .spinner {
|
|
250
|
+
width: 32px; height: 32px; border: 3px solid rgba(255,255,255,0.3);
|
|
251
|
+
border-top-color: #ff79c6; border-radius: 50%;
|
|
252
|
+
animation: framv-spin 0.8s linear infinite;
|
|
253
|
+
margin-right: 12px;
|
|
254
|
+
}
|
|
255
|
+
@keyframes framv-spin { to { transform: rotate(360deg); } }
|
|
256
|
+
`;
|
|
257
|
+
var fmt = (s) => {
|
|
258
|
+
const m = Math.floor(s / 60);
|
|
259
|
+
return `${m}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
|
260
|
+
};
|
|
261
|
+
var FramvVideoElement = class extends HTMLElement {
|
|
262
|
+
static observedAttributes = ["duration", "width", "height", "format"];
|
|
263
|
+
_player = null;
|
|
264
|
+
_stage;
|
|
265
|
+
_toolbar;
|
|
266
|
+
_btn;
|
|
267
|
+
_range;
|
|
268
|
+
_time;
|
|
269
|
+
_exportBtn;
|
|
270
|
+
_shadow;
|
|
271
|
+
_dragging = false;
|
|
272
|
+
_exporting = false;
|
|
273
|
+
constructor() {
|
|
274
|
+
super();
|
|
275
|
+
this._shadow = this.attachShadow({ mode: "open" });
|
|
276
|
+
}
|
|
277
|
+
get width() {
|
|
278
|
+
return parseInt(this.getAttribute("width") ?? "1920");
|
|
279
|
+
}
|
|
280
|
+
get height() {
|
|
281
|
+
return parseInt(this.getAttribute("height") ?? "1080");
|
|
282
|
+
}
|
|
283
|
+
get fps() {
|
|
284
|
+
return parseInt(this.getAttribute("fps") ?? "30");
|
|
285
|
+
}
|
|
286
|
+
get duration() {
|
|
287
|
+
return parseFloat(this.getAttribute("duration") ?? "5");
|
|
288
|
+
}
|
|
289
|
+
get format() {
|
|
290
|
+
return this.getAttribute("format") ?? "mp4";
|
|
291
|
+
}
|
|
292
|
+
get quality() {
|
|
293
|
+
return parseFloat(this.getAttribute("quality") ?? "0.95");
|
|
294
|
+
}
|
|
295
|
+
get player() {
|
|
296
|
+
return this._player;
|
|
297
|
+
}
|
|
298
|
+
connectedCallback() {
|
|
299
|
+
const w = this.width;
|
|
300
|
+
const h = this.height;
|
|
301
|
+
this._shadow.innerHTML = `
|
|
302
|
+
<style>${STYLES}</style>
|
|
303
|
+
<div class="framv-badge">framv video \xB7 ${this.format.toUpperCase()} \xB7 ${w}x${h}</div>
|
|
304
|
+
<div class="framv-stage" style="--framv-w:${w}px;--framv-h:${h}px">
|
|
305
|
+
<slot></slot>
|
|
306
|
+
</div>
|
|
307
|
+
<div class="framv-toolbar">
|
|
308
|
+
<button class="btn-play">\u25B6</button>
|
|
309
|
+
<input type="range" min="0" max="1000" value="0" step="1">
|
|
310
|
+
<span class="framv-time">0:00 / 0:00</span>
|
|
311
|
+
<button class="btn-export">\u2B07 Export ${this.format.toUpperCase()}</button>
|
|
312
|
+
</div>
|
|
313
|
+
`;
|
|
314
|
+
this._stage = this._shadow.querySelector(".framv-stage");
|
|
315
|
+
this._toolbar = this._shadow.querySelector(".framv-toolbar");
|
|
316
|
+
this._btn = this._shadow.querySelector(".btn-play");
|
|
317
|
+
this._range = this._shadow.querySelector("input");
|
|
318
|
+
this._time = this._shadow.querySelector(".framv-time");
|
|
319
|
+
this._exportBtn = this._shadow.querySelector(".btn-export");
|
|
320
|
+
this._adaptSize();
|
|
321
|
+
this._player = new Player(this);
|
|
322
|
+
const d = this.duration;
|
|
323
|
+
if (d > 0) this._player.setDuration(d);
|
|
324
|
+
else this._player.setDuration(5);
|
|
325
|
+
this._player.on("play", () => {
|
|
326
|
+
this._btn.textContent = "\u23F8";
|
|
327
|
+
});
|
|
328
|
+
this._player.on("pause", () => {
|
|
329
|
+
this._btn.textContent = "\u25B6";
|
|
330
|
+
});
|
|
331
|
+
this._player.on("ended", () => {
|
|
332
|
+
this._btn.textContent = "\u21BA";
|
|
333
|
+
if (this.hasAttribute("loop")) {
|
|
334
|
+
this._player.seek(0).then(() => this._player.play());
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
this._player.on("timeupdate", (t = 0) => {
|
|
338
|
+
if (!this._dragging) this._range.value = String(Math.round(t / (this._player.duration || 1) * 1e3));
|
|
339
|
+
this._time.textContent = `${fmt(t)} / ${fmt(this._player.duration)}`;
|
|
340
|
+
});
|
|
341
|
+
this._btn.addEventListener("click", () => {
|
|
342
|
+
if (this._exporting) return;
|
|
343
|
+
if (this._player.playing) {
|
|
344
|
+
this._player.pause();
|
|
345
|
+
} else if (this._player.currentTime >= this._player.duration && this._player.duration > 0) {
|
|
346
|
+
this._player.seek(0).then(() => this._player.play());
|
|
347
|
+
} else {
|
|
348
|
+
this._player.play();
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
this._range.addEventListener("mousedown", () => {
|
|
352
|
+
this._dragging = true;
|
|
353
|
+
});
|
|
354
|
+
this._range.addEventListener("mouseup", () => {
|
|
355
|
+
this._dragging = false;
|
|
356
|
+
});
|
|
357
|
+
this._range.addEventListener("input", () => {
|
|
358
|
+
const t = Number(this._range.value) / 1e3 * (this._player.duration || 1);
|
|
359
|
+
this._player.seek(t);
|
|
360
|
+
});
|
|
361
|
+
this._exportBtn.addEventListener("click", () => this._export());
|
|
362
|
+
if (this.hasAttribute("autoplay")) this._player.play();
|
|
363
|
+
new ResizeObserver(() => this._adaptSize()).observe(this);
|
|
364
|
+
}
|
|
365
|
+
disconnectedCallback() {
|
|
366
|
+
this._player?.destroy();
|
|
367
|
+
this._player = null;
|
|
368
|
+
}
|
|
369
|
+
attributeChangedCallback(name, _old, value) {
|
|
370
|
+
if (name === "duration" && this._player) {
|
|
371
|
+
this._player.setDuration(parseFloat(value) || 0);
|
|
372
|
+
}
|
|
373
|
+
if (name === "format" && this._toolbar) {
|
|
374
|
+
const badge = this._shadow.querySelector(".framv-badge");
|
|
375
|
+
if (badge) badge.textContent = `framv video \xB7 ${this.format.toUpperCase()} \xB7 ${this.width}x${this.height}`;
|
|
376
|
+
this._exportBtn.textContent = `\u2B07 Export ${this.format.toUpperCase()}`;
|
|
377
|
+
}
|
|
378
|
+
if ((name === "width" || name === "height") && this._stage) {
|
|
379
|
+
const w = this.width;
|
|
380
|
+
const h = this.height;
|
|
381
|
+
this._stage.style.setProperty("--framv-w", `${w}px`);
|
|
382
|
+
this._stage.style.setProperty("--framv-h", `${h}px`);
|
|
383
|
+
const badge = this._shadow.querySelector(".framv-badge");
|
|
384
|
+
if (badge) badge.textContent = `framv video \xB7 ${this.format.toUpperCase()} \xB7 ${w}x${h}`;
|
|
385
|
+
this._adaptSize();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
_adaptSize() {
|
|
389
|
+
const w = this.width;
|
|
390
|
+
const h = this.height;
|
|
391
|
+
const containerW = this.clientWidth || w;
|
|
392
|
+
const scale = Math.min(containerW / w, 1);
|
|
393
|
+
this._stage.style.transform = `scale(${scale})`;
|
|
394
|
+
this.style.minHeight = `${h * scale}px`;
|
|
395
|
+
}
|
|
396
|
+
async _export() {
|
|
397
|
+
if (this._exporting) return;
|
|
398
|
+
this._exporting = true;
|
|
399
|
+
const overlay = document.createElement("div");
|
|
400
|
+
overlay.className = "framv-exporting-overlay";
|
|
401
|
+
overlay.innerHTML = '<div class="spinner"></div><span>Exporting... 0%</span>';
|
|
402
|
+
const label = overlay.querySelector("span");
|
|
403
|
+
this._shadow.appendChild(overlay);
|
|
404
|
+
const container = document.createElement("div");
|
|
405
|
+
container.style.width = `${this.width}px`;
|
|
406
|
+
container.style.height = `${this.height}px`;
|
|
407
|
+
container.style.position = "relative";
|
|
408
|
+
container.style.overflow = "hidden";
|
|
409
|
+
Array.from(this.children).forEach((child) => container.appendChild(child.cloneNode(true)));
|
|
410
|
+
try {
|
|
411
|
+
const blob = await exportElement({
|
|
412
|
+
element: container,
|
|
413
|
+
settings: {
|
|
414
|
+
format: this.format,
|
|
415
|
+
fps: this.fps,
|
|
416
|
+
start: 0,
|
|
417
|
+
end: this.duration,
|
|
418
|
+
width: this.width,
|
|
419
|
+
height: this.height,
|
|
420
|
+
quality: this.quality
|
|
421
|
+
},
|
|
422
|
+
onProgress: (p) => {
|
|
423
|
+
label.textContent = `Exporting... ${Math.round(p * 100)}%`;
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
const url = URL.createObjectURL(blob);
|
|
428
|
+
const a = document.createElement("a");
|
|
429
|
+
a.href = url;
|
|
430
|
+
a.download = `framv-video.${this.format}`;
|
|
431
|
+
this._shadow.appendChild(a);
|
|
432
|
+
a.click();
|
|
433
|
+
a.remove();
|
|
434
|
+
URL.revokeObjectURL(url);
|
|
435
|
+
} catch (err) {
|
|
436
|
+
console.error("Export failed:", err);
|
|
437
|
+
label.textContent = "Export failed. Check console.";
|
|
438
|
+
} finally {
|
|
439
|
+
overlay.remove();
|
|
440
|
+
this._exporting = false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
if (!customElements.get("framv-video")) {
|
|
445
|
+
customElements.define("framv-video", FramvVideoElement);
|
|
446
|
+
}
|
|
447
|
+
export {
|
|
448
|
+
FramvVideoElement,
|
|
449
|
+
Player
|
|
450
|
+
};
|