400 lines
13 KiB
TypeScript
400 lines
13 KiB
TypeScript
import { CustomFile } from "@/types/webrtc";
|
|
import { TransferConfig } from "./TransferConfig";
|
|
import { ChunkRangeCalculator } from "@/lib/utils/ChunkRangeCalculator";
|
|
import { postLogToBackend } from "@/app/config/api";
|
|
const developmentEnv = process.env.NODE_ENV;
|
|
/**
|
|
* 🚀 Network chunk interface
|
|
*/
|
|
export interface NetworkChunk {
|
|
chunk: ArrayBuffer | null;
|
|
chunkIndex: number;
|
|
totalChunks: number;
|
|
fileOffset: number;
|
|
isLastChunk: boolean;
|
|
}
|
|
|
|
/**
|
|
* 🚀 High-performance streaming file reader
|
|
* Uses a two-layer buffering architecture: large batch reading + small network chunk sending
|
|
* Solves file reading performance bottleneck issues
|
|
*/
|
|
export class StreamingFileReader {
|
|
// Configuration parameters
|
|
private readonly BATCH_SIZE =
|
|
TransferConfig.FILE_CONFIG.CHUNK_SIZE *
|
|
TransferConfig.FILE_CONFIG.BATCH_SIZE; // 32MB batches
|
|
private readonly NETWORK_CHUNK_SIZE =
|
|
TransferConfig.FILE_CONFIG.NETWORK_CHUNK_SIZE; // 64KB network chunks
|
|
private readonly CHUNKS_PER_BATCH = this.BATCH_SIZE / this.NETWORK_CHUNK_SIZE; // 512 chunks
|
|
|
|
// File state
|
|
private file: File;
|
|
private fileReader: FileReader;
|
|
private totalFileSize: number;
|
|
|
|
// Batch buffering state
|
|
private currentBatch: ArrayBuffer | null = null; // Current 32MB batch data
|
|
private currentBatchStartOffset = 0; // Starting position of current batch in file
|
|
private currentChunkIndexInBatch = 0; // Index of current network chunk in batch
|
|
|
|
// Global state
|
|
private totalFileOffset = 0; // Current position in the entire file
|
|
private startChunkIndex = 0; // 🔧 Record the chunk index at the start of transmission
|
|
private isFinished = false;
|
|
private isReading = false; // Prevent concurrent reading
|
|
|
|
constructor(file: CustomFile, startOffset: number = 0) {
|
|
this.file = file;
|
|
this.totalFileSize = file.size;
|
|
this.totalFileOffset = startOffset;
|
|
// 🔧 Fix: When resuming, currentBatchStartOffset should start from startOffset
|
|
this.currentBatchStartOffset = startOffset;
|
|
this.fileReader = new FileReader();
|
|
|
|
// 🔧 Record the starting chunk index of the transfer, used for boundary detection
|
|
this.startChunkIndex = Math.floor(startOffset / this.NETWORK_CHUNK_SIZE);
|
|
|
|
if (developmentEnv === "development") {
|
|
const chunkRange = ChunkRangeCalculator.getChunkRange(
|
|
this.totalFileSize,
|
|
startOffset,
|
|
this.NETWORK_CHUNK_SIZE
|
|
);
|
|
postLogToBackend(
|
|
`[SEND-SUMMARY] File: ${file.name}, offset: ${startOffset}, startChunk: ${chunkRange.startChunk}, endChunk: ${chunkRange.endChunk}, willSend: ${chunkRange.totalChunks}, absoluteTotal: ${chunkRange.absoluteTotalChunks}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 🎯 Core method: Get next 64KB network chunk
|
|
*/
|
|
async getNextNetworkChunk(): Promise<NetworkChunk> {
|
|
// 1. Check if new batch needs to be loaded
|
|
if (this.needsNewBatch()) {
|
|
await this.loadNextBatch();
|
|
}
|
|
|
|
// 2. Check if end of file has been reached
|
|
if (this.isFinished || !this.currentBatch) {
|
|
return {
|
|
chunk: null,
|
|
chunkIndex: this.calculateGlobalChunkIndex(),
|
|
totalChunks: this.calculateTotalNetworkChunks(),
|
|
fileOffset: this.totalFileOffset,
|
|
isLastChunk: true,
|
|
};
|
|
}
|
|
|
|
// 3. Slice 64KB network chunk from current batch
|
|
const networkChunk = this.sliceNetworkChunkFromBatch();
|
|
const globalChunkIndex = this.calculateGlobalChunkIndex();
|
|
const isLast = this.isLastNetworkChunk(networkChunk);
|
|
|
|
// 4. Update state
|
|
this.updateChunkState(networkChunk);
|
|
|
|
// if (developmentEnv === "development") {
|
|
// const totalChunks = this.calculateTotalNetworkChunks();
|
|
|
|
// const isFirst = globalChunkIndex === this.startChunkIndex;
|
|
// const expectedLastChunk = Math.floor(
|
|
// (this.totalFileSize - 1) / this.NETWORK_CHUNK_SIZE
|
|
// );
|
|
// const isRealLast = isLast && globalChunkIndex === expectedLastChunk;
|
|
|
|
// if (isFirst || isRealLast) {
|
|
// postLogToBackend(
|
|
// `[BOUNDARY] Chunk #${globalChunkIndex}/${totalChunks}, isFirst: ${isFirst}, isLast: ${isRealLast}, startIdx: ${this.startChunkIndex}, expectedLastIdx: ${expectedLastChunk}, size: ${networkChunk.byteLength}`
|
|
// );
|
|
// }
|
|
// }
|
|
|
|
return {
|
|
chunk: networkChunk,
|
|
chunkIndex: globalChunkIndex,
|
|
totalChunks: this.calculateTotalNetworkChunks(),
|
|
fileOffset: this.totalFileOffset - networkChunk.byteLength,
|
|
isLastChunk: isLast,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 🔍 Determine if new batch needs to be loaded
|
|
*/
|
|
private needsNewBatch(): boolean {
|
|
return (
|
|
this.currentBatch === null || // No batch loaded yet
|
|
this.currentChunkIndexInBatch >= this.CHUNKS_PER_BATCH || // Current batch exhausted
|
|
this.isCurrentBatchEmpty() // Current batch has no data
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 🔍 Check if current batch is empty
|
|
*/
|
|
private isCurrentBatchEmpty(): boolean {
|
|
if (!this.currentBatch) return true;
|
|
|
|
const usedBytes = this.currentChunkIndexInBatch * this.NETWORK_CHUNK_SIZE;
|
|
return usedBytes >= this.currentBatch.byteLength;
|
|
}
|
|
|
|
/**
|
|
* 📥 Load next 32MB batch into memory
|
|
*/
|
|
private async loadNextBatch(): Promise<void> {
|
|
if (this.isReading) {
|
|
// Prevent concurrent reading
|
|
while (this.isReading) {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.isReading = true;
|
|
const startTime = performance.now();
|
|
|
|
try {
|
|
// 1. Clean up old batch memory
|
|
this.currentBatch = null;
|
|
|
|
// 2. Calculate size to read this time
|
|
const remainingFileSize = this.totalFileSize - this.totalFileOffset;
|
|
const batchSize = Math.min(this.BATCH_SIZE, remainingFileSize);
|
|
|
|
if (batchSize <= 0) {
|
|
this.isFinished = true;
|
|
return;
|
|
}
|
|
|
|
// 3. Perform large chunk file reading
|
|
const sliceStartTime = performance.now();
|
|
const fileSlice = this.file.slice(
|
|
this.totalFileOffset,
|
|
this.totalFileOffset + batchSize
|
|
);
|
|
const sliceTime = performance.now() - sliceStartTime;
|
|
|
|
// 4. Asynchronously read file data
|
|
const readStartTime = performance.now();
|
|
this.currentBatch = await this.readFileSlice(fileSlice);
|
|
const readTime = performance.now() - readStartTime;
|
|
|
|
const batchStartOffset = this.totalFileOffset;
|
|
this.currentBatchStartOffset = batchStartOffset;
|
|
|
|
// 🔧 Fix: Simplify index calculation logic within batch
|
|
// Since calculateGlobalChunkIndex now directly calculates based on totalFileOffset
|
|
// Indexing within a batch only needs to be calculated based on the starting position of the current batch
|
|
const chunkOffsetInBatch =
|
|
batchStartOffset -
|
|
Math.floor(batchStartOffset / this.BATCH_SIZE) * this.BATCH_SIZE;
|
|
this.currentChunkIndexInBatch = Math.floor(
|
|
chunkOffsetInBatch / this.NETWORK_CHUNK_SIZE
|
|
);
|
|
|
|
// Only output essential batch reading logs in development environment
|
|
if (developmentEnv === "development" && batchSize > this.BATCH_SIZE / 2) {
|
|
const totalTime = performance.now() - startTime;
|
|
const speedMBps = batchSize / 1024 / 1024 / (totalTime / 1000);
|
|
postLogToBackend(
|
|
`[BATCH-READ] 📖 size: ${(batchSize / 1024 / 1024).toFixed(
|
|
1
|
|
)}MB, speed: ${speedMBps.toFixed(1)}MB/s`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (developmentEnv === "development") {
|
|
postLogToBackend(`[DEBUG] ❌ BATCH_READ failed: ${error}`);
|
|
}
|
|
throw new Error(`Failed to load file batch: ${error}`);
|
|
} finally {
|
|
this.isReading = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 📄 Perform file reading operation
|
|
*/
|
|
private async readFileSlice(fileSlice: Blob): Promise<ArrayBuffer> {
|
|
return new Promise((resolve, reject) => {
|
|
this.fileReader.onload = () => {
|
|
const result = this.fileReader.result as ArrayBuffer;
|
|
if (result) {
|
|
resolve(result);
|
|
} else {
|
|
reject(new Error("FileReader result is null"));
|
|
}
|
|
};
|
|
|
|
this.fileReader.onerror = () => {
|
|
reject(
|
|
new Error(
|
|
`File reading failed: ${
|
|
this.fileReader.error?.message || "Unknown error"
|
|
}`
|
|
)
|
|
);
|
|
};
|
|
|
|
this.fileReader.readAsArrayBuffer(fileSlice);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* ✂️ Slice 64KB network chunk from 32MB batch
|
|
* 🆕 Fix: Calculate directly based on the position of offset in the batch, avoiding complex batch internal index calculations
|
|
*/
|
|
private sliceNetworkChunkFromBatch(): ArrayBuffer {
|
|
if (!this.currentBatch) {
|
|
throw new Error("No current batch available for slicing");
|
|
}
|
|
|
|
// 🆕 Calculated directly based on the position of offset in the batch to avoid index calculation errors within the batch
|
|
const offsetInBatch = this.totalFileOffset - this.currentBatchStartOffset;
|
|
const remainingInBatch = this.currentBatch.byteLength - offsetInBatch;
|
|
const chunkSize = Math.min(this.NETWORK_CHUNK_SIZE, remainingInBatch);
|
|
|
|
if (chunkSize <= 0) {
|
|
throw new Error("Invalid chunk size calculated");
|
|
}
|
|
|
|
const networkChunk = this.currentBatch.slice(
|
|
offsetInBatch,
|
|
offsetInBatch + chunkSize
|
|
);
|
|
|
|
// Delete frequent slice logs, only output when needed
|
|
return networkChunk;
|
|
}
|
|
|
|
/**
|
|
* 📊 Calculate global network chunk index
|
|
* 🔧 Simplified logic: directly calculate based on file offset to avoid batch boundary errors
|
|
*/
|
|
private calculateGlobalChunkIndex(): number {
|
|
// Calculate chunk index directly based on current file offset, avoiding complex batch calculations, consistent with receiver
|
|
return Math.floor(this.totalFileOffset / this.NETWORK_CHUNK_SIZE);
|
|
}
|
|
|
|
/**
|
|
* 📈 Calculate total network chunk count
|
|
*/
|
|
private calculateTotalNetworkChunks(): number {
|
|
return Math.ceil(this.totalFileSize / this.NETWORK_CHUNK_SIZE);
|
|
}
|
|
|
|
/**
|
|
* ⏭️ Update current processing state
|
|
*/
|
|
private updateChunkState(chunk: ArrayBuffer): void {
|
|
this.currentChunkIndexInBatch++;
|
|
this.totalFileOffset += chunk.byteLength;
|
|
|
|
// Check if end of file has been reached
|
|
if (this.totalFileOffset >= this.totalFileSize) {
|
|
this.isFinished = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 🏁 Check if this is the last network chunk
|
|
*/
|
|
private isLastNetworkChunk(chunk: ArrayBuffer): boolean {
|
|
return this.totalFileOffset + chunk.byteLength >= this.totalFileSize;
|
|
}
|
|
|
|
/**
|
|
* 📊 Get reading progress information
|
|
*/
|
|
public getProgress(): {
|
|
readBytes: number;
|
|
totalBytes: number;
|
|
progressPercent: number;
|
|
currentBatchInfo?: {
|
|
batchStartOffset: number;
|
|
batchSize: number;
|
|
chunkIndex: number;
|
|
totalChunks: number;
|
|
};
|
|
} {
|
|
const progressPercent =
|
|
this.totalFileSize > 0
|
|
? (this.totalFileOffset / this.totalFileSize) * 100
|
|
: 0;
|
|
|
|
const result = {
|
|
readBytes: this.totalFileOffset,
|
|
totalBytes: this.totalFileSize,
|
|
progressPercent,
|
|
} as any;
|
|
|
|
if (this.currentBatch) {
|
|
result.currentBatchInfo = {
|
|
batchStartOffset: this.currentBatchStartOffset,
|
|
batchSize: this.currentBatch.byteLength,
|
|
chunkIndex: this.currentChunkIndexInBatch,
|
|
totalChunks: Math.ceil(
|
|
this.currentBatch.byteLength / this.NETWORK_CHUNK_SIZE
|
|
),
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 🔄 Reset reader state (for restarting reading)
|
|
*/
|
|
public reset(startOffset: number = 0): void {
|
|
this.totalFileOffset = startOffset;
|
|
this.isFinished = false;
|
|
this.isReading = false;
|
|
this.currentBatch = null;
|
|
// 🔧 Fix: Reset also needs to correctly set currentBatchStartOffset
|
|
this.currentBatchStartOffset = startOffset;
|
|
this.currentChunkIndexInBatch = 0; // Reset to 0, loadNextBatch will recalculate
|
|
|
|
if (developmentEnv === "development") {
|
|
postLogToBackend(
|
|
`[DEBUG] 🔄 StreamingFileReader reset - startOffset:${startOffset}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 🧹 Cleanup and release resources
|
|
*/
|
|
public cleanup(): void {
|
|
// Abort ongoing file reading
|
|
if (this.isReading) {
|
|
this.fileReader.abort();
|
|
}
|
|
|
|
// Clean up memory
|
|
this.currentBatch = null;
|
|
this.isFinished = true;
|
|
this.isReading = false;
|
|
}
|
|
|
|
/**
|
|
* 🔍 Get debug information
|
|
*/
|
|
public getDebugInfo() {
|
|
return {
|
|
fileName: this.file.name,
|
|
fileSize: this.totalFileSize,
|
|
currentOffset: this.totalFileOffset,
|
|
isFinished: this.isFinished,
|
|
isReading: this.isReading,
|
|
hasBatch: !!this.currentBatch,
|
|
batchOffset: this.currentBatchStartOffset,
|
|
chunkInBatch: this.currentChunkIndexInBatch,
|
|
globalChunkIndex: this.calculateGlobalChunkIndex(),
|
|
totalChunks: this.calculateTotalNetworkChunks(),
|
|
};
|
|
}
|
|
}
|