fix(web): release preview media elements when they leave the page
Removed <video>/<audio> elements kept downloading until garbage collected. Over HTTP/2 nothing caps these requests, and each kept a proxied connection to the API open; scrubbing across B-roll piled them up past the 512 sockets select() handles on Windows, crashing the API.
b963e6a parent: 42bca16 added
web/src/lib/media.ts +12 -0 | new file mode 100644 | ||
| @@ -0,0 +1,12 @@ | ||
| 1 | +/** | |
| 2 | + * Lâche le fichier d'un <video> / <audio> qu'on n'utilise plus. Sans ça, l'élément retiré de la page continue | |
| 3 | + * de télécharger jusqu'à son ramasse-miettes : en HTTP/2 (Vite en HTTPS) rien ne plafonne ces requêtes, et | |
| 4 | + * chacune garde une connexion ouverte vers l'API, qui tombe au-delà de 512 (select() de Windows). | |
| 5 | + */ | |
| 6 | +export function releaseMedia(m: HTMLMediaElement | null | undefined) { | |
| 7 | + if (!m) return; | |
| 8 | + m.pause(); | |
| 9 | + m.removeAttribute("src"); | |
| 10 | + // sans src, load() coupe la requête en cours | |
| 11 | + m.load(); | |
| 12 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,12 @@ | |||
| 1 | +/** | ||
| 2 | + * Lâche le fichier d'un <video> / <audio> qu'on n'utilise plus. Sans ça, l'élément retiré de la page continue | ||
| 3 | + * de télécharger jusqu'à son ramasse-miettes : en HTTP/2 (Vite en HTTPS) rien ne plafonne ces requêtes, et | ||
| 4 | + * chacune garde une connexion ouverte vers l'API, qui tombe au-delà de 512 (select() de Windows). | ||
| 5 | + */ | ||
| 6 | +export function releaseMedia(m: HTMLMediaElement | null | undefined) { | ||
| 7 | + if (!m) return; | ||
| 8 | + m.pause(); | ||
| 9 | + m.removeAttribute("src"); | ||
| 10 | + // sans src, load() coupe la requête en cours | ||
| 11 | + m.load(); | ||
| 12 | +} | ||
modified
web/src/project/steps/montage/LayerVideo.vue +3 -1 | @@ -1,6 +1,7 @@ | ||
| 1 | 1 | <script setup lang="ts"> |
| 2 | 2 | /** Une vidéo muette de l'aperçu (un B-roll), calée sur `time` (secondes dans le média) et sur la lecture. */ |
| 3 | -import { onMounted, ref, watch } from "vue"; | |
| 3 | +import { onBeforeUnmount, onMounted, ref, watch } from "vue"; | |
| 4 | +import { releaseMedia } from "../../../lib/media"; | |
| 4 | 5 | |
| 5 | 6 | const props = defineProps<{ src: string; time: number; playing: boolean }>(); |
| 6 | 7 | const el = ref<HTMLVideoElement | null>(null); |
| @@ -15,6 +16,7 @@ function sync() { | ||
| 15 | 16 | } |
| 16 | 17 | watch(() => [props.time, props.playing, props.src], sync); |
| 17 | 18 | onMounted(sync); |
| 19 | +onBeforeUnmount(() => releaseMedia(el.value)); | |
| 18 | 20 | </script> |
| 19 | 21 | |
| 20 | 22 | <template> |
| @@ -1,6 +1,7 @@ | |||
| 1 | <script setup lang="ts"> | 1 | <script setup lang="ts"> |
| 2 | /** Une vidéo muette de l'aperçu (un B-roll), calée sur `time` (secondes dans le média) et sur la lecture. */ | 2 | /** Une vidéo muette de l'aperçu (un B-roll), calée sur `time` (secondes dans le média) et sur la lecture. */ |
| 3 | -import { onMounted, ref, watch } from "vue"; | 3 | +import { onBeforeUnmount, onMounted, ref, watch } from "vue"; |
| 4 | +import { releaseMedia } from "../../../lib/media"; | ||
| 4 | 5 | ||
| 5 | const props = defineProps<{ src: string; time: number; playing: boolean }>(); | 6 | const props = defineProps<{ src: string; time: number; playing: boolean }>(); |
| 6 | const el = ref<HTMLVideoElement | null>(null); | 7 | const el = ref<HTMLVideoElement | null>(null); |
| @@ -15,6 +16,7 @@ function sync() { | |||
| 15 | } | 16 | } |
| 16 | watch(() => [props.time, props.playing, props.src], sync); | 17 | watch(() => [props.time, props.playing, props.src], sync); |
| 17 | onMounted(sync); | 18 | onMounted(sync); |
| 19 | +onBeforeUnmount(() => releaseMedia(el.value)); | ||
| 18 | </script> | 20 | </script> |
| 19 | 21 | ||
| 20 | <template> | 22 | <template> |
modified
web/src/project/steps/montage/Preview.vue +5 -4 | @@ -16,6 +16,7 @@ import type { Playlist } from "../derush/usePlaylist"; | ||
| 16 | 16 | import SlideFrame from "../shorts/SlideFrame.vue"; |
| 17 | 17 | import { fxFrame, layersAt } from "../../../lib/transitions"; |
| 18 | 18 | import LayerVideo from "./LayerVideo.vue"; |
| 19 | +import { releaseMedia } from "../../../lib/media"; | |
| 19 | 20 | import { |
| 20 | 21 | clipStarts, isImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual, |
| 21 | 22 | } from "./types"; |
| @@ -49,7 +50,7 @@ async function copySearch(b: Beat) { | ||
| 49 | 50 | |
| 50 | 51 | const el = ref<HTMLVideoElement | null>(null); |
| 51 | 52 | onMounted(() => { props.player.video.value = el.value; }); |
| 52 | -onBeforeUnmount(() => { props.player.video.value = null; }); | |
| 53 | +onBeforeUnmount(() => { props.player.video.value = null; releaseMedia(el.value); }); | |
| 53 | 54 | |
| 54 | 55 | const t = computed(() => props.player.t.value); |
| 55 | 56 | const playing = computed(() => props.player.playing.value); |
| @@ -221,7 +222,7 @@ watch([t, playing], () => { | ||
| 221 | 222 | let a = players.get(s.id); |
| 222 | 223 | // a.src est absolue et encodée (espaces → %20) : on compare avec l'adresse résolue de la même façon |
| 223 | 224 | if (!a || a.src !== new URL(url, location.href).href) { |
| 224 | - a?.pause(); | |
| 225 | + releaseMedia(a); | |
| 225 | 226 | a = new Audio(url); |
| 226 | 227 | players.set(s.id, a); |
| 227 | 228 | } |
| @@ -231,9 +232,9 @@ watch([t, playing], () => { | ||
| 231 | 232 | if (a.paused) a.play().catch(() => {}); |
| 232 | 233 | } |
| 233 | 234 | } |
| 234 | - for (const [id, a] of players) if (!active.has(id)) { a.pause(); players.delete(id); } | |
| 235 | + for (const [id, a] of players) if (!active.has(id)) { releaseMedia(a); players.delete(id); } | |
| 235 | 236 | }); |
| 236 | -onUnmounted(() => { for (const a of players.values()) a.pause(); players.clear(); }); | |
| 237 | +onUnmounted(() => { for (const a of players.values()) releaseMedia(a); players.clear(); }); | |
| 237 | 238 | |
| 238 | 239 | // un beat posé dont l'animation n'est pas écrite reste une slide, qu'on peut relancer ; un zoom ou un B-roll |
| 239 | 240 | // vidéo se voit déjà, une étiquette dit qu'il reste à poser |
| @@ -16,6 +16,7 @@ import type { Playlist } from "../derush/usePlaylist"; | |||
| 16 | import SlideFrame from "../shorts/SlideFrame.vue"; | 16 | import SlideFrame from "../shorts/SlideFrame.vue"; |
| 17 | import { fxFrame, layersAt } from "../../../lib/transitions"; | 17 | import { fxFrame, layersAt } from "../../../lib/transitions"; |
| 18 | import LayerVideo from "./LayerVideo.vue"; | 18 | import LayerVideo from "./LayerVideo.vue"; |
| 19 | +import { releaseMedia } from "../../../lib/media"; | ||
| 19 | import { | 20 | import { |
| 20 | clipStarts, isImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual, | 21 | clipStarts, isImage, tcd, TEXT_BORDER, TEXT_FADE, TEXT_FONT, type AnimState, type Beat, type MontageView, type TextNote, type Timeline, type Visual, |
| 21 | } from "./types"; | 22 | } from "./types"; |
| @@ -49,7 +50,7 @@ async function copySearch(b: Beat) { | |||
| 49 | 50 | ||
| 50 | const el = ref<HTMLVideoElement | null>(null); | 51 | const el = ref<HTMLVideoElement | null>(null); |
| 51 | onMounted(() => { props.player.video.value = el.value; }); | 52 | onMounted(() => { props.player.video.value = el.value; }); |
| 52 | -onBeforeUnmount(() => { props.player.video.value = null; }); | 53 | +onBeforeUnmount(() => { props.player.video.value = null; releaseMedia(el.value); }); |
| 53 | 54 | ||
| 54 | const t = computed(() => props.player.t.value); | 55 | const t = computed(() => props.player.t.value); |
| 55 | const playing = computed(() => props.player.playing.value); | 56 | const playing = computed(() => props.player.playing.value); |
| @@ -221,7 +222,7 @@ watch([t, playing], () => { | |||
| 221 | let a = players.get(s.id); | 222 | let a = players.get(s.id); |
| 222 | // a.src est absolue et encodée (espaces → %20) : on compare avec l'adresse résolue de la même façon | 223 | // a.src est absolue et encodée (espaces → %20) : on compare avec l'adresse résolue de la même façon |
| 223 | if (!a || a.src !== new URL(url, location.href).href) { | 224 | if (!a || a.src !== new URL(url, location.href).href) { |
| 224 | - a?.pause(); | 225 | + releaseMedia(a); |
| 225 | a = new Audio(url); | 226 | a = new Audio(url); |
| 226 | players.set(s.id, a); | 227 | players.set(s.id, a); |
| 227 | } | 228 | } |
| @@ -231,9 +232,9 @@ watch([t, playing], () => { | |||
| 231 | if (a.paused) a.play().catch(() => {}); | 232 | if (a.paused) a.play().catch(() => {}); |
| 232 | } | 233 | } |
| 233 | } | 234 | } |
| 234 | - for (const [id, a] of players) if (!active.has(id)) { a.pause(); players.delete(id); } | 235 | + for (const [id, a] of players) if (!active.has(id)) { releaseMedia(a); players.delete(id); } |
| 235 | }); | 236 | }); |
| 236 | -onUnmounted(() => { for (const a of players.values()) a.pause(); players.clear(); }); | 237 | +onUnmounted(() => { for (const a of players.values()) releaseMedia(a); players.clear(); }); |
| 237 | 238 | ||
| 238 | // un beat posé dont l'animation n'est pas écrite reste une slide, qu'on peut relancer ; un zoom ou un B-roll | 239 | // un beat posé dont l'animation n'est pas écrite reste une slide, qu'on peut relancer ; un zoom ou un B-roll |
| 239 | // vidéo se voit déjà, une étiquette dit qu'il reste à poser | 240 | // vidéo se voit déjà, une étiquette dit qu'il reste à poser |