feat(refboard): server-generated video posters via ffmpeg

Upload pipeline now extracts poster frame (JPEG) and metadata
(width, height, duration) from videos at upload time using ffmpeg.

Server:
- Add ffmpeg to Docker image (Alpine)
- video-utils.js: probeVideo() and extractPoster() using ffprobe/ffmpeg
- Upload response includes poster_asset_key and duration

Frontend:
- VideoObject gains poster and duration fields in scene format
- VideoSprite accepts posterAssetKey + TextureManager, loads poster
  as a regular image texture on construction (no <video> needed)
- Server poster loaded/released via TextureManager ref counting
- addVideoFromUpload passes poster and duration through to scene data
- image-drop.ts forwards poster_asset_key from upload response

Result: videos with server posters render as images by default.
Zero <video> elements needed for thumbnails. Only explicit play
creates a media element.
This commit is contained in:
Hiren Kangad
2026-03-10 12:10:18 +05:30
parent 9ecb287976
commit 198497381f
7 changed files with 256 additions and 45 deletions
+2 -1
View File
@@ -27,8 +27,9 @@ COPY --from=frontend-build /build/frontend/dist ./frontend/dist
# Data directory for SQLite # Data directory for SQLite
RUN mkdir -p /app/data RUN mkdir -p /app/data
# ffmpeg for video poster/metadata extraction at upload time
# Health check utility # Health check utility
RUN apk add --no-cache wget RUN apk add --no-cache wget ffmpeg
EXPOSE 8000 EXPOSE 8000
+37 -6
View File
@@ -8,6 +8,7 @@ const { URL } = require('url');
const { authMiddleware } = require('../auth'); const { authMiddleware } = require('../auth');
const { getBoard, getCollectionMember, createImage } = require('../db'); const { getBoard, getCollectionMember, createImage } = require('../db');
const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio'); const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio');
const { probeVideo, extractPoster } = require('../video-utils');
const router = Router(); const router = Router();
@@ -89,17 +90,43 @@ function classifyMedia(mimeType) {
} }
/** /**
* Upload a media file (image or video) to MinIO as a single file. * Upload a media file (image or video) to MinIO.
* GPU handles all image scaling natively — no LOD tiers needed. * For videos: extracts poster frame + metadata via ffmpeg at upload time
* so the board never needs a <video> element for thumbnails.
*/ */
async function uploadMedia(boardId, imageId, buffer, mimetype) { async function uploadMedia(boardId, imageId, buffer, mimetype) {
const ext = MIME_TO_EXT[mimetype] || '.bin'; const ext = MIME_TO_EXT[mimetype] || '.bin';
const minioPath = `boards/${boardId}/${imageId}${ext}`; const minioPath = `boards/${boardId}/${imageId}${ext}`;
await putBuffer(minioPath, buffer, mimetype); await putBuffer(minioPath, buffer, mimetype);
const isVideo = VIDEO_MIME_TYPES.includes(mimetype);
let width = null, height = null, duration = null, posterAssetKey = null;
if (isVideo) {
// Extract metadata and poster frame server-side
const [meta, posterBuf] = await Promise.all([
probeVideo(buffer),
extractPoster(buffer),
]);
if (meta) {
width = meta.width;
height = meta.height;
duration = meta.duration;
}
if (posterBuf) {
const posterPath = `boards/${boardId}/${imageId}_poster.jpg`;
await putBuffer(posterPath, posterBuf, 'image/jpeg');
posterAssetKey = posterPath;
}
} else {
const dims = await getImageDimensions(buffer, mimetype); const dims = await getImageDimensions(buffer, mimetype);
// assetKey = minioPath so frontend can build direct URLs width = dims.width;
return { assetKey: minioPath, minioPath, width: dims.width, height: dims.height }; height = dims.height;
}
return { assetKey: minioPath, minioPath, width, height, duration, posterAssetKey };
} }
/** /**
@@ -119,7 +146,7 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
const { buffer, originalname, mimetype, size } = req.file; const { buffer, originalname, mimetype, size } = req.file;
const mediaType = classifyMedia(mimetype); const mediaType = classifyMedia(mimetype);
const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimetype); const { assetKey, minioPath, width, height, duration, posterAssetKey } = await uploadMedia(board.id, imageId, buffer, mimetype);
const publicUrl = getImageUrl(minioPath); const publicUrl = getImageUrl(minioPath);
// Save record // Save record
@@ -148,6 +175,8 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
mime_type: image.mime_type, mime_type: image.mime_type,
asset_key: image.asset_key, asset_key: image.asset_key,
media_type: image.media_type, media_type: image.media_type,
duration: duration || undefined,
poster_asset_key: posterAssetKey || undefined,
}); });
} catch (err) { } catch (err) {
if (err.code === 'LIMIT_FILE_SIZE') { if (err.code === 'LIMIT_FILE_SIZE') {
@@ -224,7 +253,7 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => {
const imageId = uuidv4(); const imageId = uuidv4();
const mediaType = classifyMedia(mimeType); const mediaType = classifyMedia(mimeType);
const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimeType); const { assetKey, minioPath, width, height, duration, posterAssetKey } = await uploadMedia(board.id, imageId, buffer, mimeType);
const publicUrl = getImageUrl(minioPath); const publicUrl = getImageUrl(minioPath);
// Save record // Save record
@@ -253,6 +282,8 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => {
mime_type: image.mime_type, mime_type: image.mime_type,
asset_key: image.asset_key, asset_key: image.asset_key,
media_type: image.media_type, media_type: image.media_type,
duration: duration || undefined,
poster_asset_key: posterAssetKey || undefined,
}); });
} catch (err) { } catch (err) {
console.error('[upload] from-url error:', err); console.error('[upload] from-url error:', err);
+130
View File
@@ -0,0 +1,130 @@
/**
* video-utils.js — Extract poster frame and metadata from video buffers using ffmpeg.
*
* Called at upload time so every video gets a poster image and cached metadata.
* The board never needs to create a <video> element just to discover dimensions
* or capture a first frame.
*/
const { execFile } = require('child_process');
const { writeFileSync, readFileSync, unlinkSync, mkdtempSync } = require('fs');
const path = require('path');
const os = require('os');
/**
* Extract video metadata (dimensions, duration, hasAudio) using ffprobe.
* Returns { width, height, duration, hasAudio } or null on failure.
*/
function probeVideo(buffer) {
return new Promise((resolve) => {
let tmpDir, tmpFile;
try {
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'refboard-vid-'));
tmpFile = path.join(tmpDir, 'input.vid');
writeFileSync(tmpFile, buffer);
} catch (err) {
console.warn('[video-utils] Failed to write temp file:', err.message);
resolve(null);
return;
}
execFile('ffprobe', [
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
tmpFile,
], { timeout: 15000 }, (err, stdout) => {
cleanup(tmpFile, tmpDir);
if (err) {
console.warn('[video-utils] ffprobe failed:', err.message);
resolve(null);
return;
}
try {
const info = JSON.parse(stdout);
const videoStream = (info.streams || []).find(s => s.codec_type === 'video');
const audioStream = (info.streams || []).find(s => s.codec_type === 'audio');
if (!videoStream) {
resolve(null);
return;
}
resolve({
width: videoStream.width || null,
height: videoStream.height || null,
duration: info.format?.duration ? parseFloat(info.format.duration) : null,
hasAudio: !!audioStream,
});
} catch (parseErr) {
console.warn('[video-utils] ffprobe parse failed:', parseErr.message);
resolve(null);
}
});
});
}
/**
* Extract a poster frame (JPEG) from a video buffer.
* Returns a Buffer containing JPEG data, or null on failure.
*/
function extractPoster(buffer) {
return new Promise((resolve) => {
let tmpDir, tmpInput, tmpOutput;
try {
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'refboard-poster-'));
tmpInput = path.join(tmpDir, 'input.vid');
tmpOutput = path.join(tmpDir, 'poster.jpg');
writeFileSync(tmpInput, buffer);
} catch (err) {
console.warn('[video-utils] Failed to write temp file:', err.message);
resolve(null);
return;
}
execFile('ffmpeg', [
'-i', tmpInput,
'-vframes', '1', // single frame
'-ss', '0.1', // skip 0.1s (avoid black leader)
'-q:v', '3', // JPEG quality (2=best, 31=worst)
'-y', // overwrite
tmpOutput,
], { timeout: 15000 }, (err) => {
if (err) {
console.warn('[video-utils] ffmpeg poster extraction failed:', err.message);
cleanup(tmpInput, tmpDir);
resolve(null);
return;
}
try {
const posterBuffer = readFileSync(tmpOutput);
cleanup(tmpInput, tmpDir, tmpOutput);
resolve(posterBuffer);
} catch (readErr) {
console.warn('[video-utils] Failed to read poster:', readErr.message);
cleanup(tmpInput, tmpDir, tmpOutput);
resolve(null);
}
});
});
}
function cleanup(...files) {
for (const f of files) {
try { unlinkSync(f); } catch { /* ignore */ }
}
// Try removing parent dirs (they're temp dirs we created)
for (const f of files) {
try {
const dir = path.dirname(f);
if (dir.includes('refboard-')) {
require('fs').rmdirSync(dir);
}
} catch { /* ignore — dir may not be empty or already removed */ }
}
}
module.exports = { probeVideo, extractPoster };
+7 -1
View File
@@ -271,7 +271,7 @@ export class SceneManager {
// Use cached native dimensions if available (avoids re-init just to discover size) // Use cached native dimensions if available (avoids re-init just to discover size)
const vw = vidData.nativeW || vidData.w; const vw = vidData.nativeW || vidData.w;
const vh = vidData.nativeH || vidData.h; const vh = vidData.nativeH || vidData.h;
const videoSprite = new VideoSprite(vidData.asset, vw, vh, videoUrl); const videoSprite = new VideoSprite(vidData.asset, vw, vh, videoUrl, vidData.poster, this.textures);
// Apply cached native dims to scene data so display matches // Apply cached native dims to scene data so display matches
if (vidData.nativeW && vidData.nativeH) { if (vidData.nativeW && vidData.nativeH) {
data.w = vw; data.w = vw;
@@ -607,6 +607,8 @@ export class SceneManager {
h: number, h: number,
x: number, x: number,
y: number, y: number,
posterAssetKey?: string,
duration?: number,
): SceneItem { ): SceneItem {
const data: VideoObject = { const data: VideoObject = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -626,6 +628,10 @@ export class SceneManager {
asset: assetKey, asset: assetKey,
muted: true, muted: true,
loop: true, loop: true,
nativeW: w,
nativeH: h,
poster: posterAssetKey,
duration,
}; };
this._createItem(data, true); this._createItem(data, true);
+3 -1
View File
@@ -55,7 +55,9 @@ function handleUploadResult(
} }
if (mediaType === 'video' && assetKey) { if (mediaType === 'video' && assetKey) {
sceneManager.addVideoFromUpload(assetKey, finalW, finalH, x, y); const posterKey: string | undefined = imgData.poster_asset_key;
const duration: number | undefined = imgData.duration;
sceneManager.addVideoFromUpload(assetKey, finalW, finalH, x, y, posterKey, duration);
} else if (assetKey) { } else if (assetKey) {
sceneManager.addImageFromUpload(assetKey, finalW, finalH, x, y); sceneManager.addImageFromUpload(assetKey, finalW, finalH, x, y);
} else { } else {
+4
View File
@@ -35,6 +35,10 @@ export interface VideoObject extends SceneObject {
/** Native video dimensions, cached after first metadata load. */ /** Native video dimensions, cached after first metadata load. */
nativeW?: number; nativeW?: number;
nativeH?: number; nativeH?: number;
/** Server-generated poster asset key (JPEG). */
poster?: string;
/** Video duration in seconds. */
duration?: number;
} }
export interface TextObject extends SceneObject { export interface TextObject extends SceneObject {
+70 -33
View File
@@ -1,20 +1,18 @@
import { Container, Sprite, Texture, Graphics } from 'pixi.js'; import { Container, Sprite, Texture, Graphics } from 'pixi.js';
import { TextureManager } from '../TextureManager';
/** /**
* VideoSprite — Container with budget-aware lazy video lifecycle. * VideoSprite — Container with budget-aware lazy video lifecycle.
* *
* Memory tiers (managed by external culling system): * Memory tiers (managed by external culling system):
* Tier 0 — placeholder only (offscreen, default state) * Tier 0 — placeholder only (offscreen, default state)
* Tier 0.5 — server poster loaded as image texture (no <video> needed!)
* Tier 1 — initialized <video> with preload='metadata' (near viewport) * Tier 1 — initialized <video> with preload='metadata' (near viewport)
* Tier 2 — poster texture captured (nearest subset / selected) * Tier 2 — client-captured poster from video frame (fallback if no server poster)
* Tier 3 — actively playing with live video texture (user-initiated) * Tier 3 — actively playing with live video texture (user-initiated)
* *
* The culling system in PixiCanvas manages budgets: * If the server provides a poster asset key at upload time, the video renders
* - MAX_INITIALIZED_VIDEOS (tier 1+) * as a plain image until explicitly played. No <video> element needed for thumbnails.
* - MAX_POSTER_VIDEOS (tier 2+)
* - MAX_PLAYING_VIDEOS (tier 3)
*
* Constructor creates only shadow + placeholder + overlay. No <video> element.
*/ */
const MAX_PLAYING_VIDEOS = 1; const MAX_PLAYING_VIDEOS = 1;
@@ -26,10 +24,13 @@ const SHADOW_LIFT = { offsetX: 6, offsetY: 8, alpha: 0.3 };
export class VideoSprite extends Container { export class VideoSprite extends Container {
readonly assetKey: string; readonly assetKey: string;
readonly videoUrl: string; readonly videoUrl: string;
readonly posterAssetKey: string | null;
private textures: TextureManager | null;
private videoEl: HTMLVideoElement | null = null; private videoEl: HTMLVideoElement | null = null;
private videoTexture: Texture | null = null; private videoTexture: Texture | null = null;
private posterTexture: Texture | null = null; private posterTexture: Texture | null = null;
private _serverPosterLoaded = false;
private _sprite: Sprite | null = null; private _sprite: Sprite | null = null;
private _shadow: Graphics; private _shadow: Graphics;
private _overlay: Graphics; private _overlay: Graphics;
@@ -48,11 +49,20 @@ export class VideoSprite extends Container {
/** Called when playback state changes (for external controls). */ /** Called when playback state changes (for external controls). */
onStateChange: (() => void) | null = null; onStateChange: (() => void) | null = null;
constructor(assetKey: string, w: number, h: number, videoUrl: string) { constructor(
assetKey: string,
w: number,
h: number,
videoUrl: string,
posterAssetKey?: string | null,
textures?: TextureManager | null,
) {
super(); super();
this.assetKey = assetKey; this.assetKey = assetKey;
this.videoUrl = videoUrl; this.videoUrl = videoUrl;
this.posterAssetKey = posterAssetKey ?? null;
this.textures = textures ?? null;
this._naturalWidth = w; this._naturalWidth = w;
this._naturalHeight = h; this._naturalHeight = h;
@@ -70,6 +80,12 @@ export class VideoSprite extends Container {
this._overlay = new Graphics(); this._overlay = new Graphics();
this._drawPlayIcon(); this._drawPlayIcon();
this.addChild(this._overlay); this.addChild(this._overlay);
// If server poster available, load it immediately as an image texture
// This is the key optimization: video thumbnail = image, no <video> needed
if (this.posterAssetKey && this.textures) {
this._loadServerPoster();
}
} }
get isPlaying(): boolean { return this._isPlaying; } get isPlaying(): boolean { return this._isPlaying; }
@@ -79,6 +95,25 @@ export class VideoSprite extends Container {
/** Expose the underlying HTMLVideoElement for external controls. Null if not initialized. */ /** Expose the underlying HTMLVideoElement for external controls. Null if not initialized. */
get videoElement(): HTMLVideoElement | null { return this.videoEl; } get videoElement(): HTMLVideoElement | null { return this.videoEl; }
// ---- Tier 0.5: Server-generated poster (loaded like an image) -----------
private async _loadServerPoster(): Promise<void> {
if (!this.posterAssetKey || !this.textures || this.destroyed) return;
try {
const tex = await this.textures.load(this.posterAssetKey);
if (this.destroyed || this._isPlaying) return;
this.posterTexture = tex;
this._hasPoster = true;
this._serverPosterLoaded = true;
this._ensureSprite(tex);
this._removePlaceholder();
} catch {
// Server poster failed — will fall back to client capture if needed
}
}
// ---- Tier 1: Initialize <video> element --------------------------------- // ---- Tier 1: Initialize <video> element ---------------------------------
/** Create <video> with preload='metadata'. Called by culling when within init budget. */ /** Create <video> with preload='metadata'. Called by culling when within init budget. */
@@ -97,35 +132,32 @@ export class VideoSprite extends Container {
this.videoEl = video; this.videoEl = video;
video.src = this.videoUrl; video.src = this.videoUrl;
this.onStateChange?.(); // notify controls of new element this.onStateChange?.();
} }
/** Tear down the <video> element. Keeps poster if present. */ /** Tear down the <video> element. Keeps poster if present. */
teardownVideo(): void { teardownVideo(): void {
if (!this._videoInitialized) return; if (!this._videoInitialized) return;
if (this._isPlaying) return; // don't tear down while user is watching if (this._isPlaying) return;
this._destroyVideoEl(); this._destroyVideoEl();
this._destroyVideoTexture(); this._destroyVideoTexture();
this._videoInitialized = false; this._videoInitialized = false;
this.onStateChange?.(); // notify controls element is gone this.onStateChange?.();
} }
// ---- Tier 2: Poster texture --------------------------------------------- // ---- Tier 2: Client-captured poster (fallback) --------------------------
/** Capture a poster frame. Requires initialized video. */ /** Capture a poster frame from video. Only needed if no server poster. */
capturePoster(): void { capturePoster(): void {
if (this._hasPoster || !this.videoEl || this.destroyed) return; if (this._hasPoster || !this.videoEl || this.destroyed) return;
if (this.videoEl.readyState >= 2) { if (this.videoEl.readyState >= 2) {
// Frame data already available — capture directly
this._captureFirstFrame(); this._captureFirstFrame();
} else if (this.videoEl.readyState >= 1) { } else if (this.videoEl.readyState >= 1) {
// Metadata loaded but no frame decoded (preload='metadata'). // preload='metadata' may not decode frames. Force a seek.
// Force a seek to trigger frame decode, capture on 'seeked'.
this._trySeekCapture(); this._trySeekCapture();
} else { } else {
// Not even metadata yet — wait for loadeddata
this.videoEl.addEventListener('loadeddata', this._onLoadedData, { once: true }); this.videoEl.addEventListener('loadeddata', this._onLoadedData, { once: true });
} }
} }
@@ -134,14 +166,19 @@ export class VideoSprite extends Container {
destroyPoster(): void { destroyPoster(): void {
if (!this._hasPoster) return; if (!this._hasPoster) return;
if (this.posterTexture) { if (this.posterTexture) {
// If sprite is showing poster, switch back to nothing
if (this._sprite && this._sprite.texture === this.posterTexture) { if (this._sprite && this._sprite.texture === this.posterTexture) {
this._removeSpriteFromTree(); this._removeSpriteFromTree();
} }
// Release via TextureManager if server poster, otherwise destroy directly
if (this._serverPosterLoaded && this.posterAssetKey && this.textures) {
this.textures.release(this.posterAssetKey);
} else {
this.posterTexture.destroy(true); this.posterTexture.destroy(true);
}
this.posterTexture = null; this.posterTexture = null;
} }
this._hasPoster = false; this._hasPoster = false;
this._serverPosterLoaded = false;
this._restorePlaceholder(); this._restorePlaceholder();
} }
@@ -157,31 +194,32 @@ export class VideoSprite extends Container {
play(): void { play(): void {
if (this._isPlaying) return; if (this._isPlaying) return;
// Ensure video element exists
if (!this._videoInitialized) this.initVideo(); if (!this._videoInitialized) this.initVideo();
if (!this.videoEl) return; if (!this.videoEl) return;
// Evict oldest if at capacity
if (activeVideos.size >= MAX_PLAYING_VIDEOS) { if (activeVideos.size >= MAX_PLAYING_VIDEOS) {
const oldest = activeVideos.values().next().value as VideoSprite; const oldest = activeVideos.values().next().value as VideoSprite;
oldest.pause(); oldest.pause();
} }
// Promote to full load for playback
this.videoEl.preload = 'auto'; this.videoEl.preload = 'auto';
this.videoEl.muted = this.muted; this.videoEl.muted = this.muted;
this.videoEl.loop = this.loop; this.videoEl.loop = this.loop;
// Create live video texture
if (!this.videoTexture) { if (!this.videoTexture) {
this.videoTexture = Texture.from(this.videoEl); this.videoTexture = Texture.from(this.videoEl);
} }
// Drop poster while playing — don't keep both resident // Drop poster while playing — don't keep both resident
if (this.posterTexture && this._hasPoster) { if (this._hasPoster && this.posterTexture) {
if (this._serverPosterLoaded && this.posterAssetKey && this.textures) {
this.textures.release(this.posterAssetKey);
} else {
this.posterTexture.destroy(true); this.posterTexture.destroy(true);
}
this.posterTexture = null; this.posterTexture = null;
this._hasPoster = false; this._hasPoster = false;
this._serverPosterLoaded = false;
} }
this._ensureSprite(this.videoTexture); this._ensureSprite(this.videoTexture);
@@ -193,7 +231,6 @@ export class VideoSprite extends Container {
this.onStateChange?.(); this.onStateChange?.();
this.videoEl.play().catch(() => { this.videoEl.play().catch(() => {
// Autoplay blocked — show placeholder
this._isPlaying = false; this._isPlaying = false;
this._overlay.visible = true; this._overlay.visible = true;
activeVideos.delete(this); activeVideos.delete(this);
@@ -208,7 +245,6 @@ export class VideoSprite extends Container {
this._overlay.visible = true; this._overlay.visible = true;
activeVideos.delete(this); activeVideos.delete(this);
this.onStateChange?.(); this.onStateChange?.();
// Keep video texture showing last frame
} }
togglePlayPause(): void { togglePlayPause(): void {
@@ -228,13 +264,10 @@ export class VideoSprite extends Container {
// ---- Visibility (called by culling system) ------------------------------ // ---- Visibility (called by culling system) ------------------------------
/** Simple visibility callback — culling system manages budgets externally. */
onVisibilityChange(visible: boolean): void { onVisibilityChange(visible: boolean): void {
if (!visible && this._isPlaying) { if (!visible && this._isPlaying) {
this.pause(); this.pause();
} }
// NOTE: init/teardown is now managed by the culling budget system,
// not by individual visibility changes.
} }
// ---- Shadow / overlay --------------------------------------------------- // ---- Shadow / overlay ---------------------------------------------------
@@ -267,7 +300,7 @@ export class VideoSprite extends Container {
this._overlay.fill({ color: 0xffffff, alpha: 0.9 }); this._overlay.fill({ color: 0xffffff, alpha: 0.9 });
} }
// ---- Media event handlers (arrow fns for stable `this`) ----------------- // ---- Media event handlers -----------------------------------------------
private _onLoadedMetadata = (): void => { private _onLoadedMetadata = (): void => {
if (!this.videoEl || this.destroyed) return; if (!this.videoEl || this.destroyed) return;
@@ -357,7 +390,6 @@ export class VideoSprite extends Container {
this._sprite = new Sprite(texture); this._sprite = new Sprite(texture);
this._sprite.width = this._naturalWidth; this._sprite.width = this._naturalWidth;
this._sprite.height = this._naturalHeight; this._sprite.height = this._naturalHeight;
// Insert after shadow (index 0), before placeholder/overlay
this.addChildAt(this._sprite, 1); this.addChildAt(this._sprite, 1);
} else { } else {
this._sprite.texture = texture; this._sprite.texture = texture;
@@ -387,7 +419,6 @@ export class VideoSprite extends Container {
const p = new Graphics(); const p = new Graphics();
p.rect(0, 0, this._naturalWidth, this._naturalHeight).fill(0x2a2a2a); p.rect(0, 0, this._naturalWidth, this._naturalHeight).fill(0x2a2a2a);
this._placeholder = p; this._placeholder = p;
// Insert after shadow
this.addChildAt(p, 1); this.addChildAt(p, 1);
} }
@@ -403,7 +434,6 @@ export class VideoSprite extends Container {
private _destroyVideoTexture(): void { private _destroyVideoTexture(): void {
if (this.videoTexture) { if (this.videoTexture) {
// If sprite is showing live texture, remove it
if (this._sprite && this._sprite.texture === this.videoTexture) { if (this._sprite && this._sprite.texture === this.videoTexture) {
this._removeSpriteFromTree(); this._removeSpriteFromTree();
this._restorePlaceholder(); this._restorePlaceholder();
@@ -417,7 +447,14 @@ export class VideoSprite extends Container {
this.pause(); this.pause();
this._destroyVideoEl(); this._destroyVideoEl();
if (this.videoTexture) { this.videoTexture.destroy(true); this.videoTexture = null; } if (this.videoTexture) { this.videoTexture.destroy(true); this.videoTexture = null; }
if (this.posterTexture) { this.posterTexture.destroy(true); this.posterTexture = null; } if (this._hasPoster && this.posterTexture) {
if (this._serverPosterLoaded && this.posterAssetKey && this.textures) {
this.textures.release(this.posterAssetKey);
} else {
this.posterTexture.destroy(true);
}
this.posterTexture = null;
}
super.destroy(options); super.destroy(options);
} }
} }