update
This commit is contained in:
BIN
.playwright-mcp/recording-panel.png
Normal file
BIN
.playwright-mcp/recording-panel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
.playwright-mcp/wcctools-dashboard.png
Normal file
BIN
.playwright-mcp/wcctools-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
.playwright-mcp/wcctools-with-element.png
Normal file
BIN
.playwright-mcp/wcctools-with-element.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -30,7 +30,7 @@
|
||||
"@git.zone/tsbundle": "^2.6.3",
|
||||
"@git.zone/tsrun": "^2.0.0",
|
||||
"@git.zone/tstest": "^3.1.3",
|
||||
"@git.zone/tswatch": "^2.3.10",
|
||||
"@git.zone/tswatch": "^2.3.11",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@types/node": "^25.0.0"
|
||||
},
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -43,8 +43,8 @@ importers:
|
||||
specifier: ^3.1.3
|
||||
version: 3.1.3(@push.rocks/smartserve@1.4.0)(socks@2.8.7)(typescript@5.9.3)
|
||||
'@git.zone/tswatch':
|
||||
specifier: ^2.3.10
|
||||
version: 2.3.10(@tiptap/pm@2.27.1)
|
||||
specifier: ^2.3.11
|
||||
version: 2.3.11(@tiptap/pm@2.27.1)
|
||||
'@push.rocks/projectinfo':
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
@@ -507,8 +507,8 @@ packages:
|
||||
resolution: {integrity: sha512-t+/cKV21JHK8X7NGAmihs5M/eMm+V+jn4R5rzfwGG97WJFAcP5qE1Os9VYtyZw3tx/NZXA2yA4abo/ELluTuRA==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tswatch@2.3.10':
|
||||
resolution: {integrity: sha512-88bdzD15mYoG0T0AUTg8ATNkV/dN5ecqfiYcQRX1gJHmLrE2yqymFGkb0W0/xWgpcRakc08V+wRbSI7pqg+EOQ==}
|
||||
'@git.zone/tswatch@2.3.11':
|
||||
resolution: {integrity: sha512-FJWOsPQ9i0INn1i7uqMD0ECrZ6bwwGQC8oFDEx9PLcaS+qHpGsYj3P9UscpW1N78P+6Yd1WFUfBh9sUQiKm+KA==}
|
||||
hasBin: true
|
||||
|
||||
'@happy-dom/global-registrator@15.11.7':
|
||||
@@ -4833,7 +4833,7 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- vue
|
||||
|
||||
'@git.zone/tswatch@2.3.10(@tiptap/pm@2.27.1)':
|
||||
'@git.zone/tswatch@2.3.11(@tiptap/pm@2.27.1)':
|
||||
dependencies:
|
||||
'@api.global/typedserver': 7.11.1(@tiptap/pm@2.27.1)
|
||||
'@git.zone/tsbundle': 2.6.3
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* FFmpegService - Handles client-side video format conversion using FFmpeg.wasm
|
||||
* Implements lazy loading to minimize initial bundle impact
|
||||
* Uses a custom worker implementation to bypass COEP/CORS issues with the standard library
|
||||
*/
|
||||
|
||||
export interface IConversionProgress {
|
||||
@@ -15,52 +15,53 @@ export interface IConversionOptions {
|
||||
onProgress?: (progress: IConversionProgress) => void;
|
||||
}
|
||||
|
||||
// Message types for worker communication
|
||||
type WorkerMessageType = 'LOAD' | 'EXEC' | 'WRITE_FILE' | 'READ_FILE' | 'DELETE_FILE' | 'LOG' | 'PROGRESS' | 'ERROR';
|
||||
|
||||
interface WorkerMessage {
|
||||
id: number;
|
||||
type: WorkerMessageType;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export class FFmpegService {
|
||||
private ffmpeg: any = null;
|
||||
private isLoading: boolean = false;
|
||||
private worker: Worker | null = null;
|
||||
private core: any = null;
|
||||
private loadPromise: Promise<void> | null = null;
|
||||
private messageId = 0;
|
||||
private pendingMessages: Map<number, { resolve: Function; reject: Function }> = new Map();
|
||||
private onLog?: (message: string) => void;
|
||||
private onProgress?: (progress: number) => void;
|
||||
|
||||
/**
|
||||
* Lazy load FFmpeg.wasm from CDN
|
||||
* Uses toBlobURL to bypass CORS restrictions
|
||||
* Lazy load FFmpeg.wasm from CDN using custom worker
|
||||
*/
|
||||
async ensureLoaded(onProgress?: (progress: IConversionProgress) => void): Promise<void> {
|
||||
if (this.ffmpeg?.loaded) return;
|
||||
if (this.worker && this.core) return;
|
||||
|
||||
if (this.loadPromise) {
|
||||
await this.loadPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.loadPromise = this.loadFFmpeg(onProgress);
|
||||
await this.loadPromise;
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
private async loadFFmpeg(onProgress?: (progress: IConversionProgress) => void): Promise<void> {
|
||||
console.log('[FFmpeg] Starting FFmpeg load with custom worker...');
|
||||
|
||||
onProgress?.({
|
||||
stage: 'loading',
|
||||
progress: 0,
|
||||
message: 'Loading FFmpeg library...'
|
||||
});
|
||||
|
||||
// Dynamic import to enable code splitting
|
||||
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
|
||||
// Import toBlobURL utility
|
||||
const { toBlobURL } = await import('@ffmpeg/util');
|
||||
|
||||
this.ffmpeg = new FFmpeg();
|
||||
|
||||
// Set up progress listener
|
||||
this.ffmpeg.on('progress', ({ progress }: { progress: number }) => {
|
||||
onProgress?.({
|
||||
stage: 'converting',
|
||||
progress: Math.round(progress * 100),
|
||||
message: `Converting video... ${Math.round(progress * 100)}%`
|
||||
});
|
||||
});
|
||||
|
||||
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm';
|
||||
// Use jsdelivr CDN (has proper CORS/CORP headers)
|
||||
const coreBaseURL = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/umd';
|
||||
|
||||
onProgress?.({
|
||||
stage: 'loading',
|
||||
@@ -68,11 +69,133 @@ export class FFmpegService {
|
||||
message: 'Downloading FFmpeg core (~31MB)...'
|
||||
});
|
||||
|
||||
await this.ffmpeg.load({
|
||||
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
|
||||
wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
|
||||
console.log('[FFmpeg] Creating blob URLs...');
|
||||
const coreURL = await toBlobURL(`${coreBaseURL}/ffmpeg-core.js`, 'text/javascript');
|
||||
const wasmURL = await toBlobURL(`${coreBaseURL}/ffmpeg-core.wasm`, 'application/wasm');
|
||||
console.log('[FFmpeg] Blob URLs created');
|
||||
|
||||
onProgress?.({
|
||||
stage: 'loading',
|
||||
progress: 50,
|
||||
message: 'Initializing FFmpeg...'
|
||||
});
|
||||
|
||||
// Create custom worker code that bypasses @ffmpeg/ffmpeg wrapper issues
|
||||
const workerCode = `
|
||||
let ffmpeg = null;
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { id, type, data } = e.data;
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'LOAD': {
|
||||
const { coreURL, wasmURL } = data;
|
||||
console.log('[FFmpeg Worker] Loading core...');
|
||||
importScripts(coreURL);
|
||||
|
||||
console.log('[FFmpeg Worker] Initializing with WASM...');
|
||||
ffmpeg = await self.createFFmpegCore({
|
||||
mainScriptUrlOrBlob: coreURL + '#' + btoa(JSON.stringify({ wasmURL }))
|
||||
});
|
||||
|
||||
// Set up logging
|
||||
ffmpeg.setLogger((log) => {
|
||||
self.postMessage({ type: 'LOG', data: log });
|
||||
});
|
||||
|
||||
// Set up progress
|
||||
ffmpeg.setProgress((progress) => {
|
||||
self.postMessage({ type: 'PROGRESS', data: progress });
|
||||
});
|
||||
|
||||
console.log('[FFmpeg Worker] Core initialized successfully');
|
||||
self.postMessage({ id, type: 'LOAD', data: true });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'EXEC': {
|
||||
const { args, timeout = -1 } = data;
|
||||
ffmpeg.setTimeout(timeout);
|
||||
ffmpeg.exec(...args);
|
||||
const ret = ffmpeg.ret;
|
||||
ffmpeg.reset();
|
||||
self.postMessage({ id, type: 'EXEC', data: ret });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'WRITE_FILE': {
|
||||
const { path, fileData } = data;
|
||||
ffmpeg.FS.writeFile(path, fileData);
|
||||
self.postMessage({ id, type: 'WRITE_FILE', data: true });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'READ_FILE': {
|
||||
const { path } = data;
|
||||
const fileData = ffmpeg.FS.readFile(path);
|
||||
self.postMessage({ id, type: 'READ_FILE', data: fileData }, [fileData.buffer]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DELETE_FILE': {
|
||||
const { path } = data;
|
||||
ffmpeg.FS.unlink(path);
|
||||
self.postMessage({ id, type: 'DELETE_FILE', data: true });
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error('Unknown message type: ' + type);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[FFmpeg Worker] Error:', err);
|
||||
self.postMessage({ id, type: 'ERROR', data: err.message });
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
// Create worker from blob
|
||||
const workerBlob = new Blob([workerCode], { type: 'text/javascript' });
|
||||
const workerURL = URL.createObjectURL(workerBlob);
|
||||
this.worker = new Worker(workerURL);
|
||||
|
||||
// Set up message handler
|
||||
this.worker.onmessage = (e: MessageEvent<WorkerMessage>) => {
|
||||
const { id, type, data } = e.data;
|
||||
|
||||
if (type === 'LOG') {
|
||||
console.log('[FFmpeg Log]', data);
|
||||
this.onLog?.(data.message || data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'PROGRESS') {
|
||||
this.onProgress?.(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.pendingMessages.get(id);
|
||||
if (pending) {
|
||||
this.pendingMessages.delete(id);
|
||||
if (type === 'ERROR') {
|
||||
pending.reject(new Error(data));
|
||||
} else {
|
||||
pending.resolve(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.worker.onerror = (e) => {
|
||||
console.error('[FFmpeg] Worker error:', e);
|
||||
};
|
||||
|
||||
// Initialize FFmpeg in worker
|
||||
console.log('[FFmpeg] Initializing worker...');
|
||||
await this.sendMessage('LOAD', { coreURL, wasmURL });
|
||||
this.core = true; // Mark as loaded
|
||||
console.log('[FFmpeg] Worker initialized successfully');
|
||||
|
||||
onProgress?.({
|
||||
stage: 'loading',
|
||||
progress: 100,
|
||||
@@ -80,6 +203,14 @@ export class FFmpegService {
|
||||
});
|
||||
}
|
||||
|
||||
private sendMessage(type: WorkerMessageType, data?: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++this.messageId;
|
||||
this.pendingMessages.set(id, { resolve, reject });
|
||||
this.worker!.postMessage({ id, type, data });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert WebM blob to MP4
|
||||
*/
|
||||
@@ -92,9 +223,16 @@ export class FFmpegService {
|
||||
throw new Error('File size exceeds 2GB limit for conversion');
|
||||
}
|
||||
|
||||
await this.ensureLoaded(onProgress);
|
||||
// Set up progress callback
|
||||
this.onProgress = (progress: number) => {
|
||||
onProgress?.({
|
||||
stage: 'converting',
|
||||
progress: Math.round(progress * 100),
|
||||
message: `Converting video... ${Math.round(progress * 100)}%`
|
||||
});
|
||||
};
|
||||
|
||||
const { fetchFile } = await import('@ffmpeg/util');
|
||||
await this.ensureLoaded(onProgress);
|
||||
|
||||
onProgress?.({
|
||||
stage: 'converting',
|
||||
@@ -102,25 +240,25 @@ export class FFmpegService {
|
||||
message: 'Preparing video for conversion...'
|
||||
});
|
||||
|
||||
// Read input blob as Uint8Array
|
||||
const inputData = new Uint8Array(await inputBlob.arrayBuffer());
|
||||
|
||||
// Write input file to virtual filesystem
|
||||
await this.ffmpeg.writeFile('input.webm', await fetchFile(inputBlob));
|
||||
await this.sendMessage('WRITE_FILE', { path: 'input.webm', fileData: inputData });
|
||||
|
||||
// Execute conversion with optimized settings for web playback
|
||||
// -c:v libx264 - H.264 video codec (universal compatibility)
|
||||
// -preset fast - Balance between speed and compression
|
||||
// -crf 23 - Quality level (lower = better quality, larger file)
|
||||
// -c:a aac - AAC audio codec
|
||||
// -movflags +faststart - Enable streaming playback
|
||||
await this.ffmpeg.exec([
|
||||
'-i', 'input.webm',
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'fast',
|
||||
'-crf', '23',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-movflags', '+faststart',
|
||||
'output.mp4'
|
||||
]);
|
||||
await this.sendMessage('EXEC', {
|
||||
args: [
|
||||
'-i', 'input.webm',
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'fast',
|
||||
'-crf', '23',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-movflags', '+faststart',
|
||||
'output.mp4'
|
||||
]
|
||||
});
|
||||
|
||||
onProgress?.({
|
||||
stage: 'finalizing',
|
||||
@@ -129,11 +267,11 @@ export class FFmpegService {
|
||||
});
|
||||
|
||||
// Read output file
|
||||
const data = await this.ffmpeg.readFile('output.mp4');
|
||||
const outputData: Uint8Array = await this.sendMessage('READ_FILE', { path: 'output.mp4' });
|
||||
|
||||
// Clean up virtual filesystem
|
||||
await this.ffmpeg.deleteFile('input.webm');
|
||||
await this.ffmpeg.deleteFile('output.mp4');
|
||||
await this.sendMessage('DELETE_FILE', { path: 'input.webm' });
|
||||
await this.sendMessage('DELETE_FILE', { path: 'output.mp4' });
|
||||
|
||||
onProgress?.({
|
||||
stage: 'finalizing',
|
||||
@@ -141,24 +279,26 @@ export class FFmpegService {
|
||||
message: 'Conversion complete!'
|
||||
});
|
||||
|
||||
return new Blob([data.buffer], { type: 'video/mp4' });
|
||||
return new Blob([new Uint8Array(outputData)], { type: 'video/mp4' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if FFmpeg is currently loaded
|
||||
*/
|
||||
get isLoaded(): boolean {
|
||||
return this.ffmpeg?.loaded ?? false;
|
||||
return this.worker !== null && this.core !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate FFmpeg worker to free resources
|
||||
*/
|
||||
async terminate(): Promise<void> {
|
||||
if (this.ffmpeg) {
|
||||
await this.ffmpeg.terminate();
|
||||
this.ffmpeg = null;
|
||||
if (this.worker) {
|
||||
this.worker.terminate();
|
||||
this.worker = null;
|
||||
this.core = null;
|
||||
this.loadPromise = null;
|
||||
this.pendingMessages.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user