Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
Layers, Crop, Scissors, RotateCw, Volume2, Type,
SlidersHorizontal, Zap, AlertTriangle, Github, Copy
} from "lucide-react";
import { formatBytes } from "@/lib/fileSizeRisk";
import OnboardingTour from "./OnboardingTour";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";

Expand Down Expand Up @@ -210,6 +211,7 @@ export default function VideoEditor() {
recommendedPreset,
currentTime,
toggleSound,
fileRisk,
} = useVideoEditor();

useKeyboardShortcuts({
Expand Down Expand Up @@ -237,6 +239,12 @@ export default function VideoEditor() {

const toggleSection = (key: keyof typeof openSections) =>
setOpenSections((prev) => ({ ...prev, [key]: !prev[key] }));
const [warningDismissed, setWarningDismissed] = useState(false);

// Reset dismissal when a new file is loaded
useEffect(() => {
setWarningDismissed(false);
}, [file?.name]);
const downloadRef = useRef<HTMLDivElement>(null);

/**
Expand Down Expand Up @@ -405,10 +413,33 @@ export default function VideoEditor() {
)}
</div>

{file && file.size > 100 * 1024 * 1024 && (
<p className="text-[var(--warning)] text-sm">
⚠️ Large file - processing may take several minutes
</p>
{file && fileRisk !== "safe" && !warningDismissed && (
<div
role="status"
aria-live="polite"
className="flex items-start gap-3 p-4 bg-film-50 border border-film-200 rounded-xl text-film-800 text-sm animate-fade-in"
>
<AlertTriangle size={18} className="shrink-0 mt-0.5 text-film-500" />
<div className="flex-1">
<p className="font-heading font-bold text-sm">
{fileRisk === "very-large" ? "Very large file detected" : "Large file detected"}
{file ? ` (${formatBytes(file.size)})` : ""}
</p>
<p className="text-film-600 text-sm mt-1">
Browser-based FFmpeg processing may be slow or fail due to memory limits. For best results, use files under 500 MB. Export is still available but may take longer or fail.
</p>
</div>
<div className="flex flex-col gap-2 shrink-0">
<button
type="button"
onClick={() => setWarningDismissed(true)}
className="px-3 py-1.5 bg-[var(--border)] border border-[var(--border)] rounded-lg text-sm font-semibold hover:opacity-80 transition-colors whitespace-nowrap"
aria-label="Dismiss large file warning"
>
Dismiss
</button>
</div>
</div>
)}
{file && (
<div className={cn(
Expand Down
14 changes: 14 additions & 0 deletions src/hooks/useVideoEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition,
import { DEFAULT_RECIPE, SPEED_STEPS } from "@/lib/constants";
import { getPresetById } from "@/lib/presets";
import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg";
import { analyzeFileRisk, FileRiskLevel } from "@/lib/fileSizeRisk";
import { suggestPreset } from "@/lib/presetSuggestion";
import { validateDimensions, getDownscaledDimensions } from "@/utils/video-validation";

Expand Down Expand Up @@ -159,6 +160,7 @@ export function useVideoEditor() {
const [result, setResult] = useState<ExportResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [fileError, setFileError] = useState("");
const [fileRisk, setFileRisk] = useState<FileRiskLevel>("safe");
const [exportStartedAt, setExportStartedAt] = useState<number | null>(null);
const exportAbortControllerRef = useRef<AbortController | null>(null);
const exportCancelledRef = useRef(false);
Expand Down Expand Up @@ -396,6 +398,17 @@ export function useVideoEditor() {
try {
const { width, height, duration: dur } = await extractMetadata(selectedFile);

// Analyze file risk (optional device memory if available)
try {
const deviceMemory = typeof navigator !== "undefined" && (navigator as any).deviceMemory;
const deviceMemoryGb = typeof deviceMemory === "number" ? deviceMemory : undefined;
const risk = analyzeFileRisk(selectedFile, { deviceMemoryGb });
setFileRisk(risk);
} catch (e) {
// Non-fatal: keep default 'safe' on analysis failure
setFileRisk("safe");
}

// Layer 5: Resolution check
const dimensionCheck = validateDimensions(width, height);
if (dimensionCheck === "blocked") {
Expand Down Expand Up @@ -715,5 +728,6 @@ export function useVideoEditor() {
recommendedPreset,
currentTime,
toggleSound,
fileRisk,
};
}
52 changes: 52 additions & 0 deletions src/lib/fileSizeRisk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export type FileRiskLevel = "safe" | "large" | "very-large";

export interface FileRiskThresholds {
large?: number; // bytes
veryLarge?: number; // bytes
}

const DEFAULT_THRESHOLDS: Required<FileRiskThresholds> = {
large: 500 * 1024 * 1024, // 500MB
veryLarge: 1024 * 1024 * 1024, // 1GB
};

/**
* Analyze a File and return a simple risk level.
* Optionally adjusts thresholds based on `deviceMemoryGb` (if provided).
*/
export function analyzeFileRisk(
file: File,
opts?: FileRiskThresholds & { deviceMemoryGb?: number }
): FileRiskLevel {
const deviceMemoryGb = opts?.deviceMemoryGb;
const thresholds: Required<FileRiskThresholds> = {
large: opts?.large ?? DEFAULT_THRESHOLDS.large,
veryLarge: opts?.veryLarge ?? DEFAULT_THRESHOLDS.veryLarge,
};

// If device memory is known, scale thresholds down for low-memory devices.
// This keeps the logic lightweight and conservative: less memory -> stricter thresholds.
if (typeof deviceMemoryGb === "number" && isFinite(deviceMemoryGb) && deviceMemoryGb > 0) {
// Scale factor ranges [0.25, 1]. 8GB and above => 1 (no change).
const factor = Math.max(0.25, Math.min(1, deviceMemoryGb / 8));
thresholds.large = Math.round(thresholds.large * factor);
thresholds.veryLarge = Math.round(thresholds.veryLarge * factor);
}

const size = file.size;
if (size >= thresholds.veryLarge) return "very-large";
if (size >= thresholds.large) return "large";
return "safe";
}

export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let v = bytes / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
64 changes: 64 additions & 0 deletions src/lib/tests/fileSizeRisk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { analyzeFileRisk, formatBytes } from '../fileSizeRisk';

// Helper to create a fake File-like object with a size property.
function fakeFileWithSize(size: number): File {
// Cast to File because tests run in jsdom; we only need `.size`.
return { size } as unknown as File;
}

describe('fileSizeRisk', () => {
it('detects safe files < 500MB', () => {
const size = 499 * 1024 * 1024; // 499 MB
const f = fakeFileWithSize(size);
const r = analyzeFileRisk(f);
expect(r).toBe('safe');
});

it('detects large files >= 500MB', () => {
const size = 600 * 1024 * 1024; // 600 MB
const f = fakeFileWithSize(size);
const r = analyzeFileRisk(f);
expect(r).toBe('large');
});

it('detects very-large files >= 1GB', () => {
const size = 1.1 * 1024 * 1024 * 1024; // 1.1 GB
const f = fakeFileWithSize(Math.floor(size));
const r = analyzeFileRisk(f);
expect(r).toBe('very-large');
});

it('respects custom threshold overrides', () => {
const size = 350 * 1024 * 1024; // 350 MB
const f = fakeFileWithSize(size);
// Override thresholds so this file becomes 'large'
const r1 = analyzeFileRisk(f, { large: 300 * 1024 * 1024, veryLarge: 900 * 1024 * 1024 });
expect(r1).toBe('large');

// With a higher large threshold, it should be safe
const r2 = analyzeFileRisk(f, { large: 400 * 1024 * 1024, veryLarge: 900 * 1024 * 1024 });
expect(r2).toBe('safe');
});

it('formats bytes into KB, MB, GB correctly', () => {
expect(formatBytes(800)).toBe('800 B');
expect(formatBytes(2048)).toBe('2 KB');
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
expect(formatBytes(3 * 1024 * 1024 * 1024)).toBe('3.0 GB');
});

it('scales thresholds for low-memory devices (more conservative)', () => {
const size = 400 * 1024 * 1024; // 400 MB
const f = fakeFileWithSize(size);

// Default without device memory: 400MB is 'safe' (default large ~500MB)
expect(analyzeFileRisk(f)).toBe('safe');

// On a 4GB device thresholds scale down (factor 0.5) => large threshold becomes ~250MB
expect(analyzeFileRisk(f, { deviceMemoryGb: 4 })).toBe('large');

// On a high-memory device (16GB) thresholds shouldn't be stricter than default
expect(analyzeFileRisk(f, { deviceMemoryGb: 16 })).toBe('safe');
});
});
Loading