forked from Goutam/lynkeduppro-crm
1000 lines
44 KiB
TypeScript
1000 lines
44 KiB
TypeScript
'use client';
|
||
|
||
import { AnimatePresence, motion } from 'framer-motion';
|
||
import { nanoid } from 'nanoid';
|
||
import { useEffect, useRef, useState } from 'react';
|
||
|
||
import { FILTER_PRESETS } from '../../constants';
|
||
import { useFocusTrap } from '../../hooks/useFocusTrap';
|
||
import { editFilterCss } from '../../lib/edits';
|
||
import { summarizeEdits } from '../../lib/versions';
|
||
import { bakeVideo } from '../../lib/videoBake';
|
||
import { blobToWavBase64, wavBase64ToBlob } from '../../lib/audioCapture';
|
||
import { useAIProvider } from '../aiContext';
|
||
import { VoiceButton } from './VoiceButton';
|
||
import {
|
||
normalizeSegments,
|
||
outputDuration,
|
||
sampleOverlay,
|
||
sourceToOutputTime,
|
||
} from '../../lib/videoTimeline';
|
||
import { Icon, type IconName } from '../../icons';
|
||
import { useGallery, useGalleryStoreApi } from '../../store/context';
|
||
import type { EditState, MediaId, MediaItem, VideoOverlay, VideoSegment } from '../../types';
|
||
import { Annotations, type AnnotationTool } from './Annotations';
|
||
import { addToAlbumPicker, confirmAction } from '../modals';
|
||
|
||
type Tab = 'trim' | 'crop' | 'overlay' | 'filters' | 'adjust' | 'markup' | 'audio' | 'export';
|
||
const TABS: Tab[] = ['trim', 'crop', 'overlay', 'filters', 'adjust', 'markup', 'audio', 'export'];
|
||
const TAB_LABEL: Record<Tab, string> = {
|
||
trim: 'Trim & Split',
|
||
crop: 'Crop & Rotate',
|
||
overlay: 'Overlays & Text',
|
||
filters: 'Filters',
|
||
adjust: 'Adjust',
|
||
markup: 'Markup',
|
||
audio: 'Audio',
|
||
export: 'Export',
|
||
};
|
||
|
||
const ANN_TOOLS: Array<{ tool: AnnotationTool; icon: IconName; label: string }> = [
|
||
{ tool: 'rect', icon: 'crop', label: 'Rectangle' },
|
||
{ tool: 'ellipse', icon: 'info', label: 'Oval' },
|
||
{ tool: 'arrow', icon: 'chevron-right', label: 'Arrow' },
|
||
{ tool: 'double-arrow', icon: 'aspect', label: 'Measure' },
|
||
{ tool: 'text', icon: 'tag', label: 'Text' },
|
||
{ tool: 'freehand', icon: 'adjust', label: 'Draw' },
|
||
];
|
||
const COLORS = ['#ff3b30', '#ffcc00', '#34c759', '#0a84ff', '#ffffff', '#000000'];
|
||
const CROP_PRESETS: Array<{ label: string; ratio: number | null }> = [
|
||
{ label: 'Original', ratio: null },
|
||
{ label: '1:1', ratio: 1 },
|
||
{ label: '16:9', ratio: 16 / 9 },
|
||
{ label: '9:16', ratio: 9 / 16 },
|
||
{ label: '4:3', ratio: 4 / 3 },
|
||
];
|
||
const QUALITIES: Array<{ label: string; maxDim: number }> = [
|
||
{ label: '480p', maxDim: 854 },
|
||
{ label: '720p', maxDim: 1280 },
|
||
{ label: '1080p', maxDim: 1920 },
|
||
];
|
||
|
||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||
return new Promise((res, rej) => {
|
||
const fr = new FileReader();
|
||
fr.onload = () => res(fr.result as string);
|
||
fr.onerror = rej;
|
||
fr.readAsDataURL(blob);
|
||
});
|
||
}
|
||
const fmt = (s: number) =>
|
||
`${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}.${Math.floor((s % 1) * 10)}`;
|
||
|
||
/** Centered crop rect for an aspect ratio within a WxH frame (fractions 0..1). */
|
||
function centeredCrop(ratio: number | null, aspect: number) {
|
||
if (ratio == null) return { x: 0, y: 0, width: 1, height: 1 };
|
||
if (ratio > aspect) {
|
||
const h = aspect / ratio;
|
||
return { x: 0, y: (1 - h) / 2, width: 1, height: h };
|
||
}
|
||
const w = ratio / aspect;
|
||
return { x: (1 - w) / 2, y: 0, width: w, height: 1 };
|
||
}
|
||
|
||
export function VideoEditor() {
|
||
const api = useGalleryStoreApi();
|
||
const editorId = useGallery((s) => s.editorId);
|
||
const media = useGallery((s) => s.media);
|
||
const item = media.find((m) => m.id === editorId) ?? null;
|
||
|
||
const [tab, setTab] = useState<Tab>('trim');
|
||
const [edits, setEdits] = useState<EditState>({ adjustments: {} });
|
||
const [annTool, setAnnTool] = useState<AnnotationTool>('rect');
|
||
const [annColor, setAnnColor] = useState('#ff3b30');
|
||
const [duration, setDuration] = useState(0);
|
||
const [playhead, setPlayhead] = useState(0);
|
||
const [playing, setPlaying] = useState(false);
|
||
const [selOverlay, setSelOverlay] = useState<string | null>(null);
|
||
const provider = useAIProvider();
|
||
const [denoiseBusy, setDenoiseBusy] = useState(false);
|
||
const [denoiseErr, setDenoiseErr] = useState<string | null>(null);
|
||
const [previewH, setPreviewH] = useState(360);
|
||
const [baking, setBaking] = useState(false);
|
||
const [progress, setProgress] = useState(0);
|
||
const [exportError, setExportError] = useState<string | null>(null);
|
||
|
||
const dialogRef = useRef<HTMLDivElement>(null);
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const annoWrapRef = useRef<HTMLDivElement>(null);
|
||
const overlayFileRef = useRef<HTMLInputElement>(null);
|
||
const watermarkFileRef = useRef<HTMLInputElement>(null);
|
||
const musicFileRef = useRef<HTMLInputElement>(null);
|
||
const cancelRef = useRef<() => void>(() => api.getState().closeEditor());
|
||
useFocusTrap(dialogRef, Boolean(item) && item?.kind === 'video', () => cancelRef.current());
|
||
|
||
// Reset when a different video opens.
|
||
useEffect(() => {
|
||
setEdits(item?.edits ? { ...item.edits } : { adjustments: {} });
|
||
setTab('trim');
|
||
setProgress(0);
|
||
setPlayhead(0);
|
||
setSelOverlay(null);
|
||
setExportError(null);
|
||
}, [item?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// Track the preview box height (drives text-overlay font sizing).
|
||
useEffect(() => {
|
||
const el = annoWrapRef.current;
|
||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||
const ro = new ResizeObserver(() => setPreviewH(el.clientHeight || 360));
|
||
ro.observe(el);
|
||
return () => ro.disconnect();
|
||
}, [item?.id]);
|
||
|
||
// Keep preview playback within the first→last segment span; track the playhead.
|
||
useEffect(() => {
|
||
const v = videoRef.current;
|
||
if (!v) return;
|
||
const onTime = () => {
|
||
setPlayhead(v.currentTime);
|
||
const segs = edits.segments && edits.segments.length ? edits.segments : null;
|
||
const lo = segs ? segs[0]!.start : (edits.trim?.start ?? 0);
|
||
const hi = segs ? segs[segs.length - 1]!.end : (edits.trim?.end ?? (duration || v.duration));
|
||
if (v.currentTime >= hi) v.currentTime = lo;
|
||
};
|
||
// The preview <video> has no native controls (they would rotate with the frame),
|
||
// so the custom control bar drives it — keep its play/pause icon in sync here.
|
||
const onPlay = () => setPlaying(true);
|
||
const onPause = () => setPlaying(false);
|
||
v.addEventListener('timeupdate', onTime);
|
||
v.addEventListener('play', onPlay);
|
||
v.addEventListener('pause', onPause);
|
||
return () => {
|
||
v.removeEventListener('timeupdate', onTime);
|
||
v.removeEventListener('play', onPlay);
|
||
v.removeEventListener('pause', onPause);
|
||
};
|
||
}, [edits.trim, edits.segments, duration]);
|
||
|
||
useEffect(() => {
|
||
if (videoRef.current) videoRef.current.muted = !!edits.audio?.muted;
|
||
}, [edits.audio?.muted]);
|
||
|
||
if (!item || item.kind !== 'video') return null;
|
||
|
||
const update = (patch: Partial<EditState>) => setEdits((e) => ({ ...e, ...patch }));
|
||
const setAdj = (key: string, val: number) =>
|
||
update({ adjustments: { ...edits.adjustments, [key]: val } });
|
||
|
||
// ---- segments ----
|
||
const segments: VideoSegment[] =
|
||
edits.segments && edits.segments.length
|
||
? edits.segments
|
||
: duration
|
||
? [{ id: 'seg0', start: 0, end: duration, speed: 1 }]
|
||
: [];
|
||
const setSegments = (segs: VideoSegment[]) => update({ segments: segs, trim: undefined });
|
||
const splitAtPlayhead = () => {
|
||
const t = playhead;
|
||
const next: VideoSegment[] = [];
|
||
for (const s of segments) {
|
||
if (t > s.start + 0.1 && t < s.end - 0.1) {
|
||
next.push({ ...s, end: t }, { id: nanoid(6), start: t, end: s.end, speed: s.speed });
|
||
} else next.push(s);
|
||
}
|
||
setSegments(next);
|
||
};
|
||
const outDur = outputDuration(normalizeSegments(edits, duration || 0));
|
||
|
||
// ---- overlays ----
|
||
const overlays: VideoOverlay[] = edits.overlays ?? [];
|
||
const setOverlays = (list: VideoOverlay[]) => update({ overlays: list });
|
||
const outputTime = sourceToOutputTime(normalizeSegments(edits, duration || 0), playhead);
|
||
const addOverlay = (o: VideoOverlay) => {
|
||
setOverlays([...overlays, o]);
|
||
setSelOverlay(o.id);
|
||
};
|
||
const patchOverlay = (id: string, patch: Partial<VideoOverlay>) =>
|
||
setOverlays(overlays.map((o) => (o.id === id ? { ...o, ...patch } : o)));
|
||
const addImageOverlay = (file?: File, watermark = false) => {
|
||
if (!file) return;
|
||
void blobToDataUrl(file).then((src) =>
|
||
addOverlay({
|
||
id: nanoid(6),
|
||
kind: 'image',
|
||
src,
|
||
x: 0.05,
|
||
y: 0.05,
|
||
scale: watermark ? 0.22 : 0.3,
|
||
opacity: watermark ? 0.85 : 1,
|
||
rotation: 0,
|
||
watermark,
|
||
}),
|
||
);
|
||
};
|
||
const addTextOverlay = () =>
|
||
addOverlay({
|
||
id: nanoid(6),
|
||
kind: 'text',
|
||
text: 'Your text',
|
||
color: '#ffffff',
|
||
fontSize: 0.09,
|
||
bold: true,
|
||
x: 0.1,
|
||
y: 0.8,
|
||
scale: 0.3,
|
||
opacity: 1,
|
||
rotation: 0,
|
||
});
|
||
const runVideoDenoise = async () => {
|
||
if (!provider?.denoiseAudio) return;
|
||
setDenoiseBusy(true);
|
||
setDenoiseErr(null);
|
||
try {
|
||
const resp = await fetch(item.src);
|
||
const blob = await resp.blob();
|
||
// Decode the video's audio track → 48 kHz mono WAV → RunPod denoise → clean WAV.
|
||
const wav48 = await blobToWavBase64(blob, 48000);
|
||
const cleaned = await provider.denoiseAudio(wav48);
|
||
const url = URL.createObjectURL(wavBase64ToBlob(cleaned));
|
||
update({ audio: { ...edits.audio, denoisedSrc: url } });
|
||
} catch (e) {
|
||
setDenoiseErr(
|
||
e instanceof Error ? e.message : 'Could not clean the audio (keep clips under ~30s).',
|
||
);
|
||
} finally {
|
||
setDenoiseBusy(false);
|
||
}
|
||
};
|
||
const addKeyframe = (id: string) => {
|
||
const o = overlays.find((x) => x.id === id);
|
||
if (!o) return;
|
||
const s = sampleOverlay(o, outputTime);
|
||
const kf = {
|
||
t: Math.round(outputTime * 100) / 100,
|
||
x: s.x,
|
||
y: s.y,
|
||
scale: s.scale,
|
||
rotation: s.rotation,
|
||
opacity: s.opacity,
|
||
};
|
||
const rest = (o.keyframes ?? []).filter((k) => Math.abs(k.t - kf.t) > 0.05);
|
||
patchOverlay(id, { keyframes: [...rest, kf].sort((a, b) => a.t - b.t) });
|
||
};
|
||
|
||
const isDirty = () =>
|
||
!!edits.filter ||
|
||
(edits.annotations?.length ?? 0) > 0 ||
|
||
Object.keys(edits.adjustments ?? {}).length > 0 ||
|
||
!!edits.trim ||
|
||
!!edits.segments ||
|
||
!!edits.overlay ||
|
||
(edits.overlays?.length ?? 0) > 0 ||
|
||
!!edits.crop ||
|
||
!!edits.rotation ||
|
||
!!edits.flipH ||
|
||
!!edits.flipV ||
|
||
!!edits.audio;
|
||
|
||
const cancel = () => {
|
||
if (!isDirty()) return api.getState().closeEditor();
|
||
confirmAction({
|
||
title: 'Discard changes?',
|
||
message: 'Your edits to this video have not been saved.',
|
||
confirmLabel: 'Discard',
|
||
danger: true,
|
||
onConfirm: () => api.getState().closeEditor(),
|
||
});
|
||
};
|
||
cancelRef.current = cancel;
|
||
|
||
const pickMusic = (file?: File) => {
|
||
if (!file) return;
|
||
void blobToDataUrl(file).then((src) =>
|
||
update({
|
||
audio: { ...edits.audio, musicSrc: src, musicVolume: edits.audio?.musicVolume ?? 0.8 },
|
||
}),
|
||
);
|
||
};
|
||
|
||
const save = async (asCopy: boolean) => {
|
||
setBaking(true);
|
||
setProgress(0);
|
||
setExportError(null);
|
||
const changes = summarizeEdits(edits);
|
||
try {
|
||
const svg = annoWrapRef.current?.querySelector('svg') as SVGSVGElement | null;
|
||
const { blob, durationSec, poster } = await bakeVideo(item.src, edits, {
|
||
annotationsSvg: edits.annotations?.length ? svg : null,
|
||
onProgress: setProgress,
|
||
});
|
||
const targetId = asCopy ? (nanoid(10) as MediaId) : item.id;
|
||
const uploaded = await api.getState().uploadBlob(targetId, blob);
|
||
const src = uploaded ?? (await blobToDataUrl(blob));
|
||
const patch: Partial<MediaItem> = {
|
||
src,
|
||
thumbnail: undefined,
|
||
poster,
|
||
duration: durationSec,
|
||
mime: blob.type,
|
||
edits: undefined,
|
||
editedAt: Date.now(),
|
||
analyzedAt: undefined,
|
||
};
|
||
if (asCopy) {
|
||
const newId = api.getState().duplicateWithEdits(item.id, { ...patch, id: targetId }, changes);
|
||
api.getState().closeEditor();
|
||
addToAlbumPicker([newId]);
|
||
} else {
|
||
api.getState().addVersion(item.id, patch, changes);
|
||
api.getState().closeEditor();
|
||
}
|
||
} catch (err) {
|
||
// Surface the failure instead of silently pretending success.
|
||
// eslint-disable-next-line no-console
|
||
console.error('[VideoEditor] export failed:', err);
|
||
setExportError(
|
||
err instanceof Error && /cross-origin|tainted|secur/i.test(err.message)
|
||
? "This video can't be exported in-browser (cross-origin source without CORS). Re-import it, or host it with CORS enabled."
|
||
: 'Export failed. Your edits were saved as a new version so nothing is lost.',
|
||
);
|
||
// Preserve the edit intent as a version so history is never lost.
|
||
if (asCopy) {
|
||
const copyId = nanoid(10) as MediaId;
|
||
api.getState().duplicateWithEdits(item.id, { id: copyId, edits, editedAt: Date.now() }, changes);
|
||
} else {
|
||
api.getState().addVersion(item.id, { edits, editedAt: Date.now() }, changes);
|
||
}
|
||
} finally {
|
||
setBaking(false);
|
||
}
|
||
};
|
||
|
||
const filterCss = editFilterCss(edits);
|
||
// Live preview for Crop & Rotate. videoBake applies these on export; without
|
||
// mirroring them on the preview the Rotate/Flip/Crop buttons look like they do
|
||
// nothing. Quarter-turn rotations are scaled to fit the frame.
|
||
const pvRot = (((edits.rotation ?? 0) % 360) + 360) % 360;
|
||
const pvQuarter = pvRot === 90 || pvRot === 270;
|
||
const pvAspect = (item.width || 16) / (item.height || 9);
|
||
const pvFit = pvQuarter ? Math.min(pvAspect, 1 / pvAspect) : 1;
|
||
const previewTransform =
|
||
pvRot || edits.flipH || edits.flipV
|
||
? `rotate(${pvRot}deg) scale(${(edits.flipH ? -1 : 1) * pvFit}, ${(edits.flipV ? -1 : 1) * pvFit})`
|
||
: undefined;
|
||
const pvCrop = edits.crop;
|
||
const previewClip = pvCrop
|
||
? `inset(${(pvCrop.y * 100).toFixed(3)}% ${((1 - pvCrop.x - pvCrop.width) * 100).toFixed(3)}% ${((1 - pvCrop.y - pvCrop.height) * 100).toFixed(3)}% ${(pvCrop.x * 100).toFixed(3)}%)`
|
||
: undefined;
|
||
const sel = overlays.find((o) => o.id === selOverlay) ?? null;
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
<motion.div
|
||
ref={dialogRef}
|
||
className="apg-editor"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={`Edit ${item.name}`}
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.16 }}
|
||
>
|
||
<div className="apg-editor__bar">
|
||
<button type="button" className="apg-iconbtn" aria-label="Cancel" onClick={cancel}>
|
||
<Icon name="close" />
|
||
</button>
|
||
<div style={{ fontWeight: 600 }}>Edit Video · {item.name}</div>
|
||
<div style={{ flex: 1 }} />
|
||
{baking ? (
|
||
<span style={{ fontSize: 13, color: 'var(--apg-text-secondary)' }}>
|
||
Exporting… {Math.round(progress * 100)}%
|
||
</span>
|
||
) : null}
|
||
<button type="button" className="apg-btn" onClick={() => setEdits({ adjustments: {} })} disabled={baking}>
|
||
Revert
|
||
</button>
|
||
<button type="button" className="apg-btn" onClick={() => void save(true)} disabled={baking} title="Export as a new video">
|
||
Save as Copy
|
||
</button>
|
||
<button type="button" className="apg-btn apg-btn--primary" onClick={() => void save(false)} disabled={baking}>
|
||
{baking ? 'Exporting…' : 'Save'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="apg-editor__body">
|
||
<div className="apg-editor__canvaswrap">
|
||
<div ref={annoWrapRef} style={{ position: 'relative', maxWidth: '100%', maxHeight: '100%' }}>
|
||
<video
|
||
ref={videoRef}
|
||
src={item.src}
|
||
playsInline
|
||
crossOrigin="anonymous"
|
||
onClick={() => {
|
||
const v = videoRef.current;
|
||
if (v) (v.paused ? v.play() : v.pause());
|
||
}}
|
||
style={{
|
||
maxWidth: '100%',
|
||
maxHeight: '70vh',
|
||
display: 'block',
|
||
filter: filterCss || undefined,
|
||
transform: previewTransform,
|
||
clipPath: previewClip,
|
||
transition: 'transform 0.15s ease',
|
||
}}
|
||
onLoadedMetadata={(e) => {
|
||
const d = e.currentTarget.duration || 0;
|
||
setDuration(d);
|
||
setPreviewH(annoWrapRef.current?.clientHeight || 360);
|
||
e.currentTarget.currentTime = segments[0]?.start ?? 0;
|
||
}}
|
||
/>
|
||
{/* Live overlay preview (keyframe-interpolated at the current output time). */}
|
||
{overlays.map((o) => {
|
||
const s = sampleOverlay(o, outputTime);
|
||
if (!s.visible) return null;
|
||
const selectedRing = o.id === selOverlay ? '0 0 0 2px var(--apg-accent)' : undefined;
|
||
if (o.kind === 'image') {
|
||
return (
|
||
<img
|
||
key={o.id}
|
||
src={o.src}
|
||
alt=""
|
||
onClick={() => setSelOverlay(o.id)}
|
||
style={{
|
||
position: 'absolute',
|
||
left: `${s.x * 100}%`,
|
||
top: `${s.y * 100}%`,
|
||
width: `${s.scale * 100}%`,
|
||
opacity: s.opacity,
|
||
transform: `rotate(${s.rotation}deg)`,
|
||
transformOrigin: 'top left',
|
||
boxShadow: selectedRing,
|
||
cursor: 'pointer',
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<div
|
||
key={o.id}
|
||
onClick={() => setSelOverlay(o.id)}
|
||
style={{
|
||
position: 'absolute',
|
||
left: `${s.x * 100}%`,
|
||
top: `${s.y * 100}%`,
|
||
opacity: s.opacity,
|
||
transform: `rotate(${s.rotation}deg)`,
|
||
transformOrigin: 'top left',
|
||
color: o.color ?? '#fff',
|
||
fontSize: `${(o.fontSize ?? 0.08) * previewH}px`,
|
||
fontWeight: o.bold ? 700 : 500,
|
||
lineHeight: 1,
|
||
whiteSpace: 'pre',
|
||
textShadow: '0 1px 3px rgba(0,0,0,0.7)',
|
||
outline: o.id === selOverlay ? '2px solid var(--apg-accent)' : undefined,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{o.text}
|
||
</div>
|
||
);
|
||
})}
|
||
<Annotations
|
||
annotations={edits.annotations ?? []}
|
||
editable={tab === 'markup'}
|
||
tool={annTool}
|
||
color={annColor}
|
||
onChange={(annotations) => update({ annotations })}
|
||
/>
|
||
</div>
|
||
|
||
{/* Custom transport — OUTSIDE the transformed <video>, so rotate/flip/crop
|
||
only affect the frame, never the controls (the old native `controls`
|
||
bar rotated with the video, which looked broken). */}
|
||
<div className="apg-vedit__transport">
|
||
<button
|
||
type="button"
|
||
className="apg-iconbtn"
|
||
aria-label={playing ? 'Pause' : 'Play'}
|
||
onClick={() => {
|
||
const v = videoRef.current;
|
||
if (v) (v.paused ? v.play() : v.pause());
|
||
}}
|
||
>
|
||
<Icon name={playing ? 'pause' : 'play'} size={18} />
|
||
</button>
|
||
<input
|
||
type="range"
|
||
className="apg-vedit__scrub"
|
||
min={0}
|
||
max={duration || 0}
|
||
step={0.01}
|
||
value={Math.min(playhead, duration || 0)}
|
||
aria-label="Seek"
|
||
onChange={(e) => {
|
||
const v = videoRef.current;
|
||
if (v) v.currentTime = Number(e.target.value);
|
||
}}
|
||
/>
|
||
<span className="apg-vedit__time">
|
||
{fmt(playhead)} / {fmt(duration)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="apg-editor__panel apg-scroll">
|
||
<div className="apg-editor__tabs">
|
||
{TABS.map((t) => (
|
||
<button
|
||
key={t}
|
||
type="button"
|
||
className={['apg-editor__tab', tab === t ? 'apg-editor__tab--active' : ''].join(' ')}
|
||
onClick={() => setTab(t)}
|
||
>
|
||
{TAB_LABEL[t]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{exportError ? (
|
||
<div className="apg-editor__error" role="alert">
|
||
{exportError}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* ---------- TRIM & SPLIT (multi-segment) ---------- */}
|
||
{tab === 'trim' ? (
|
||
<div className="apg-vedit__panel">
|
||
<div className="apg-vedit__hint">
|
||
Keep multiple parts at different speeds. Split at the playhead, then trim or delete
|
||
each segment. Output length: <strong>{fmt(outDur)}</strong>.
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
|
||
<button type="button" className="apg-btn apg-btn--primary apg-btn--small" onClick={splitAtPlayhead}>
|
||
<Icon name="crop" size={13} /> Split at {fmt(playhead)}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="apg-btn apg-btn--small"
|
||
onClick={() =>
|
||
setSegments([
|
||
...segments,
|
||
{ id: nanoid(6), start: 0, end: Math.min(duration, 3), speed: 1 },
|
||
])
|
||
}
|
||
>
|
||
<Icon name="plus" size={13} /> Add
|
||
</button>
|
||
</div>
|
||
{segments.map((s, i) => (
|
||
<div key={s.id} className="apg-vedit__seg">
|
||
<div className="apg-vedit__seg-head">
|
||
<strong>Segment {i + 1}</strong>
|
||
<span className="apg-vedit__seg-dur">{fmt(Math.max(0, s.end - s.start))}</span>
|
||
{segments.length > 1 ? (
|
||
<button
|
||
type="button"
|
||
className="apg-iconbtn apg-iconbtn--sm"
|
||
aria-label="Delete segment"
|
||
onClick={() => setSegments(segments.filter((x) => x.id !== s.id))}
|
||
>
|
||
<Icon name="trash" size={14} />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<label className="apg-vedit__row">
|
||
<span>Start {fmt(s.start)}</span>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={duration || 0}
|
||
step={0.1}
|
||
value={s.start}
|
||
onChange={(e) => {
|
||
const start = Math.min(Number(e.target.value), s.end - 0.2);
|
||
setSegments(segments.map((x) => (x.id === s.id ? { ...x, start } : x)));
|
||
if (videoRef.current) videoRef.current.currentTime = start;
|
||
}}
|
||
/>
|
||
</label>
|
||
<label className="apg-vedit__row">
|
||
<span>End {fmt(s.end)}</span>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={duration || 0}
|
||
step={0.1}
|
||
value={s.end}
|
||
onChange={(e) => {
|
||
const end = Math.max(Number(e.target.value), s.start + 0.2);
|
||
setSegments(segments.map((x) => (x.id === s.id ? { ...x, end } : x)));
|
||
}}
|
||
/>
|
||
</label>
|
||
<label className="apg-vedit__row">
|
||
<span>Speed {(s.speed ?? 1).toFixed(2)}×</span>
|
||
<input
|
||
type="range"
|
||
min={0.25}
|
||
max={3}
|
||
step={0.05}
|
||
value={s.speed ?? 1}
|
||
onChange={(e) =>
|
||
setSegments(
|
||
segments.map((x) => (x.id === s.id ? { ...x, speed: Number(e.target.value) } : x)),
|
||
)
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* ---------- CROP & ROTATE ---------- */}
|
||
{tab === 'crop' ? (
|
||
<div className="apg-vedit__panel">
|
||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||
<button
|
||
type="button"
|
||
className="apg-btn apg-btn--small"
|
||
onClick={() => update({ rotation: (((edits.rotation ?? 0) + 90) % 360) })}
|
||
>
|
||
<Icon name="rotate" size={14} /> Rotate 90°
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={['apg-btn apg-btn--small', edits.flipH ? 'apg-btn--primary' : ''].join(' ')}
|
||
onClick={() => update({ flipH: !edits.flipH })}
|
||
>
|
||
Flip H
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={['apg-btn apg-btn--small', edits.flipV ? 'apg-btn--primary' : ''].join(' ')}
|
||
onClick={() => update({ flipV: !edits.flipV })}
|
||
>
|
||
Flip V
|
||
</button>
|
||
</div>
|
||
<div className="apg-vedit__hint" style={{ marginTop: 10 }}>Crop aspect</div>
|
||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||
{CROP_PRESETS.map((p) => (
|
||
<button
|
||
key={p.label}
|
||
type="button"
|
||
className="apg-btn apg-btn--small"
|
||
onClick={() => {
|
||
const aspect = (item.width || 16) / (item.height || 9);
|
||
update({ crop: p.ratio == null ? undefined : centeredCrop(p.ratio, aspect) });
|
||
}}
|
||
>
|
||
{p.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="apg-vedit__hint" style={{ marginTop: 8 }}>
|
||
Crop + rotation + flips are baked into the exported video.
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* ---------- OVERLAYS & TEXT (with keyframe animation) ---------- */}
|
||
{tab === 'overlay' ? (
|
||
<div className="apg-vedit__panel">
|
||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||
<button type="button" className="apg-btn apg-btn--small apg-btn--primary" onClick={() => overlayFileRef.current?.click()}>
|
||
<Icon name="image" size={13} /> Image
|
||
</button>
|
||
<button type="button" className="apg-btn apg-btn--small" onClick={addTextOverlay}>
|
||
<Icon name="tag" size={13} /> Text
|
||
</button>
|
||
<button type="button" className="apg-btn apg-btn--small" onClick={() => watermarkFileRef.current?.click()}>
|
||
<Icon name="image" size={13} /> Watermark
|
||
</button>
|
||
</div>
|
||
<input ref={overlayFileRef} type="file" accept="image/*" hidden onChange={(e) => { addImageOverlay(e.target.files?.[0] ?? undefined, false); e.target.value = ''; }} />
|
||
<input ref={watermarkFileRef} type="file" accept="image/*" hidden onChange={(e) => { addImageOverlay(e.target.files?.[0] ?? undefined, true); e.target.value = ''; }} />
|
||
|
||
{overlays.length === 0 ? (
|
||
<div className="apg-vedit__hint">
|
||
Add a logo, sticker, watermark, or animated title. Select one to animate it with
|
||
keyframes (move / fade / scale over time).
|
||
</div>
|
||
) : (
|
||
<div className="apg-vedit__ovlist">
|
||
{overlays.map((o) => (
|
||
<button
|
||
key={o.id}
|
||
type="button"
|
||
className={['apg-vedit__ovitem', o.id === selOverlay ? 'is-sel' : ''].join(' ')}
|
||
onClick={() => setSelOverlay(o.id)}
|
||
>
|
||
<Icon name={o.kind === 'text' ? 'tag' : 'image'} size={13} />
|
||
<span className="apg-vedit__ovlabel">
|
||
{o.kind === 'text' ? o.text || 'Text' : o.watermark ? 'Watermark' : 'Image'}
|
||
</span>
|
||
{o.keyframes?.length ? <span className="apg-vedit__kfbadge">{o.keyframes.length}◆</span> : null}
|
||
<span
|
||
className="apg-iconbtn apg-iconbtn--sm"
|
||
role="button"
|
||
aria-label="Delete overlay"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setOverlays(overlays.filter((x) => x.id !== o.id));
|
||
if (selOverlay === o.id) setSelOverlay(null);
|
||
}}
|
||
>
|
||
<Icon name="trash" size={13} />
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{sel ? (
|
||
<div className="apg-vedit__ovedit">
|
||
{sel.kind === 'text' ? (
|
||
<>
|
||
<label className="apg-vedit__row">
|
||
<span>Text</span>
|
||
<input
|
||
className="apg-modal__input"
|
||
value={sel.text ?? ''}
|
||
onChange={(e) => patchOverlay(sel.id, { text: e.target.value })}
|
||
/>
|
||
</label>
|
||
<VoiceButton
|
||
label="Speak → text"
|
||
onText={(t) => {
|
||
const cur = sel.text && sel.text !== 'Your text' ? sel.text : '';
|
||
patchOverlay(sel.id, { text: cur ? `${cur} ${t}` : t });
|
||
}}
|
||
/>
|
||
<div style={{ display: 'flex', gap: 6, margin: '4px 0' }}>
|
||
{COLORS.map((c) => (
|
||
<button
|
||
key={c}
|
||
type="button"
|
||
aria-label={`Color ${c}`}
|
||
onClick={() => patchOverlay(sel.id, { color: c })}
|
||
style={{
|
||
width: 22, height: 22, borderRadius: '50%', background: c,
|
||
border: sel.color === c ? '2px solid var(--apg-accent)' : '1px solid var(--apg-separator)',
|
||
cursor: 'pointer',
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
<label className="apg-vedit__row">
|
||
<span>Size {Math.round((sel.fontSize ?? 0.09) * 100)}</span>
|
||
<input type="range" min={0.03} max={0.3} step={0.005} value={sel.fontSize ?? 0.09}
|
||
onChange={(e) => patchOverlay(sel.id, { fontSize: Number(e.target.value) })} />
|
||
</label>
|
||
</>
|
||
) : null}
|
||
{(['x', 'y', 'scale', 'opacity'] as const).map((k) => (
|
||
<label key={k} className="apg-vedit__row">
|
||
<span style={{ textTransform: 'capitalize' }}>{k} {(sel[k] ?? (k === 'opacity' ? 1 : 0)).toFixed(2)}</span>
|
||
<input
|
||
type="range"
|
||
min={k === 'scale' ? 0.05 : 0}
|
||
max={1}
|
||
step={0.01}
|
||
value={sel[k] ?? (k === 'opacity' ? 1 : 0)}
|
||
onChange={(e) => patchOverlay(sel.id, { [k]: Number(e.target.value) } as Partial<VideoOverlay>)}
|
||
/>
|
||
</label>
|
||
))}
|
||
<label className="apg-vedit__row">
|
||
<span>Rotation {Math.round(sel.rotation ?? 0)}°</span>
|
||
<input type="range" min={-180} max={180} step={1} value={sel.rotation ?? 0}
|
||
onChange={(e) => patchOverlay(sel.id, { rotation: Number(e.target.value) })} />
|
||
</label>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<label className="apg-vedit__row" style={{ flex: 1 }}>
|
||
<span>Appear {fmt(sel.in ?? 0)}</span>
|
||
<input type="range" min={0} max={outDur} step={0.1} value={sel.in ?? 0}
|
||
onChange={(e) => patchOverlay(sel.id, { in: Number(e.target.value) })} />
|
||
</label>
|
||
<label className="apg-vedit__row" style={{ flex: 1 }}>
|
||
<span>Hide {fmt(sel.out ?? outDur)}</span>
|
||
<input type="range" min={0} max={outDur} step={0.1} value={sel.out ?? outDur}
|
||
onChange={(e) => patchOverlay(sel.id, { out: Number(e.target.value) })} />
|
||
</label>
|
||
</div>
|
||
<div className="apg-vedit__hint" style={{ marginTop: 6 }}>
|
||
Keyframes (animate over time) — position the playhead, set the transform, then
|
||
Add keyframe. Two+ keyframes animate between them.
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<button type="button" className="apg-btn apg-btn--small apg-btn--primary" onClick={() => addKeyframe(sel.id)}>
|
||
◆ Add keyframe @ {fmt(outputTime)}
|
||
</button>
|
||
{(sel.keyframes ?? []).map((k, i) => (
|
||
<span key={i} className="apg-vedit__kf">
|
||
{fmt(k.t)}
|
||
<button
|
||
type="button"
|
||
aria-label="Delete keyframe"
|
||
onClick={() =>
|
||
patchOverlay(sel.id, { keyframes: (sel.keyframes ?? []).filter((_, j) => j !== i) })
|
||
}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* ---------- FILTERS ---------- */}
|
||
{tab === 'filters' ? (
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, padding: 12 }}>
|
||
{Object.entries(FILTER_PRESETS).map(([key, preset]) => (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
className={['apg-btn', edits.filter === key || (key === 'original' && !edits.filter) ? 'apg-btn--primary' : ''].join(' ')}
|
||
onClick={() => update({ filter: key === 'original' ? undefined : key })}
|
||
>
|
||
{preset.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* ---------- ADJUST ---------- */}
|
||
{tab === 'adjust' ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, padding: 12 }}>
|
||
{(['brightness', 'contrast', 'saturation', 'warmth'] as const).map((k) => (
|
||
<label key={k} style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 }}>
|
||
<span style={{ textTransform: 'capitalize', color: 'var(--apg-text-secondary)' }}>{k}</span>
|
||
<input type="range" min={-1} max={1} step={0.01} value={edits.adjustments[k] ?? 0}
|
||
onChange={(e) => setAdj(k, Number(e.target.value))} />
|
||
</label>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* ---------- MARKUP ---------- */}
|
||
{tab === 'markup' ? (
|
||
<div style={{ padding: 12 }}>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10 }}>
|
||
{ANN_TOOLS.map((t) => (
|
||
<button key={t.tool} type="button"
|
||
className={['apg-btn', annTool === t.tool ? 'apg-btn--primary' : ''].join(' ')}
|
||
style={{ padding: '6px 8px' }} title={t.label} onClick={() => setAnnTool(t.tool)}>
|
||
<Icon name={t.icon} size={15} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
||
{COLORS.map((c) => (
|
||
<button key={c} type="button" aria-label={`Color ${c}`} onClick={() => setAnnColor(c)}
|
||
style={{ width: 24, height: 24, borderRadius: '50%', background: c,
|
||
border: annColor === c ? '2px solid var(--apg-accent)' : '1px solid var(--apg-separator)', cursor: 'pointer' }} />
|
||
))}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button type="button" className="apg-btn" onClick={() => update({ annotations: (edits.annotations ?? []).slice(0, -1) })}>Undo</button>
|
||
<button type="button" className="apg-btn" onClick={() => update({ annotations: [] })}>Clear</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* ---------- AUDIO ---------- */}
|
||
{tab === 'audio' ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 12, fontSize: 13 }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<input type="checkbox" checked={!!edits.audio?.muted}
|
||
onChange={(e) => update({ audio: { ...edits.audio, muted: e.target.checked } })} />
|
||
Mute original audio
|
||
</label>
|
||
{provider?.denoiseAudio ? (
|
||
edits.audio?.denoisedSrc ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||
<span style={{ color: '#34c759' }}>✓ Background noise reduced (AI)</span>
|
||
<button
|
||
type="button"
|
||
className="apg-btn apg-btn--small"
|
||
onClick={() => update({ audio: { ...edits.audio, denoisedSrc: undefined } })}
|
||
>
|
||
Undo
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="apg-btn apg-btn--primary"
|
||
disabled={denoiseBusy}
|
||
onClick={() => void runVideoDenoise()}
|
||
>
|
||
<Icon name="wand" size={14} />{' '}
|
||
{denoiseBusy ? 'Cleaning audio…' : 'Reduce background noise (AI)'}
|
||
</button>
|
||
)
|
||
) : null}
|
||
{denoiseErr ? (
|
||
<p style={{ color: '#ff6b6b', fontSize: 12, margin: 0 }}>{denoiseErr}</p>
|
||
) : null}
|
||
{!edits.audio?.muted ? (
|
||
<label className="apg-vedit__row">
|
||
<span>Original volume {Math.round((edits.audio?.originalVolume ?? 1) * 100)}%</span>
|
||
<input type="range" min={0} max={1} step={0.05} value={edits.audio?.originalVolume ?? 1}
|
||
onChange={(e) => update({ audio: { ...edits.audio, originalVolume: Number(e.target.value) } })} />
|
||
</label>
|
||
) : null}
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<label className="apg-vedit__row" style={{ flex: 1 }}>
|
||
<span>Fade in {(edits.audio?.fadeIn ?? 0).toFixed(1)}s</span>
|
||
<input type="range" min={0} max={5} step={0.1} value={edits.audio?.fadeIn ?? 0}
|
||
onChange={(e) => update({ audio: { ...edits.audio, fadeIn: Number(e.target.value) } })} />
|
||
</label>
|
||
<label className="apg-vedit__row" style={{ flex: 1 }}>
|
||
<span>Fade out {(edits.audio?.fadeOut ?? 0).toFixed(1)}s</span>
|
||
<input type="range" min={0} max={5} step={0.1} value={edits.audio?.fadeOut ?? 0}
|
||
onChange={(e) => update({ audio: { ...edits.audio, fadeOut: Number(e.target.value) } })} />
|
||
</label>
|
||
</div>
|
||
<button type="button" className="apg-btn apg-btn--primary" onClick={() => musicFileRef.current?.click()}>
|
||
<Icon name="play" size={14} /> {edits.audio?.musicSrc ? 'Replace Music' : 'Add Music'}
|
||
</button>
|
||
<input ref={musicFileRef} type="file" accept="audio/*" hidden
|
||
onChange={(e) => { pickMusic(e.target.files?.[0] ?? undefined); e.target.value = ''; }} />
|
||
{edits.audio?.musicSrc ? (
|
||
<>
|
||
<label className="apg-vedit__row">
|
||
<span>Music volume {Math.round((edits.audio.musicVolume ?? 0.8) * 100)}%</span>
|
||
<input type="range" min={0} max={1} step={0.05} value={edits.audio.musicVolume ?? 0.8}
|
||
onChange={(e) => update({ audio: { ...edits.audio, musicVolume: Number(e.target.value) } })} />
|
||
</label>
|
||
<button type="button" className="apg-btn" onClick={() => update({ audio: { ...edits.audio, musicSrc: undefined } })}>Remove Music</button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* ---------- EXPORT (quality + poster) ---------- */}
|
||
{tab === 'export' ? (
|
||
<div className="apg-vedit__panel">
|
||
<div className="apg-vedit__hint">Resolution (longest side)</div>
|
||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||
{QUALITIES.map((q) => (
|
||
<button
|
||
key={q.label}
|
||
type="button"
|
||
className={['apg-btn apg-btn--small', (edits.export?.maxDim ?? 1280) === q.maxDim ? 'apg-btn--primary' : ''].join(' ')}
|
||
onClick={() => update({ export: { ...edits.export, maxDim: q.maxDim } })}
|
||
>
|
||
{q.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<label className="apg-vedit__row" style={{ marginTop: 10 }}>
|
||
<span>Frame rate {edits.export?.fps ?? 30} fps</span>
|
||
<input type="range" min={15} max={60} step={1} value={edits.export?.fps ?? 30}
|
||
onChange={(e) => update({ export: { ...edits.export, fps: Number(e.target.value) } })} />
|
||
</label>
|
||
<div className="apg-vedit__hint" style={{ marginTop: 10 }}>Poster / thumbnail</div>
|
||
<button type="button" className="apg-btn apg-btn--small" onClick={() => update({ posterTime: outputTime })}>
|
||
<Icon name="image" size={13} /> Use current frame ({fmt(outputTime)})
|
||
</button>
|
||
{edits.posterTime != null ? (
|
||
<div className="apg-vedit__hint">Poster set at {fmt(edits.posterTime)}.</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
);
|
||
}
|