codebase overhaul (#697)

This commit is contained in:
Maze
2026-01-31 00:20:04 +01:00
committed by GitHub
parent 0173db9944
commit 7bf0984698
469 changed files with 36184 additions and 32931 deletions
-295
View File
@@ -1,295 +0,0 @@
/* An `action` is a unique verb that is associated with certain thing that can be done on OpenCut.
* For example, toggling playback or seeking.
*/
import {
useEffect,
useRef,
useState,
useCallback,
MutableRefObject,
} from "react";
// Simple event emitter for action changes
class ActionEmitter {
private listeners: Array<(actions: Action[]) => void> = [];
subscribe(listener: (actions: Action[]) => void) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
emit(actions: Action[]) {
this.listeners.forEach((listener) => listener(actions));
}
}
const actionEmitter = new ActionEmitter();
export type Action =
| "toggle-play" // Toggle play/pause state
| "stop-playback" // Stop playback
| "seek-forward" // Seek forward in playback
| "seek-backward" // Seek backward in playback
| "frame-step-forward" // Step forward by one frame
| "frame-step-backward" // Step backward by one frame
| "jump-forward" // Jump forward by 5 seconds
| "jump-backward" // Jump backward by 5 seconds
| "goto-start" // Go to timeline start
| "goto-end" // Go to timeline end
| "split-element" // Split element at current time
| "delete-selected" // Delete selected elements
| "select-all" // Select all elements
| "duplicate-selected" // Duplicate selected element
| "toggle-snapping" // Toggle snapping
| "undo" // Undo last action
| "redo" // Redo last undone action
| "copy-selected" // Copy selected elements to clipboard
| "paste-selected"; // Paste elements from clipboard at playhead
/**
* Defines the arguments, if present for a given type that is required to be passed on
* invocation and will be passed to action handlers.
*
* This type is supposed to be an object with the key being one of the actions mentioned above.
* The value to the key can be anything.
* If an action has no argument, you do not need to add it to this type.
*
* NOTE: We can't enforce type checks to make sure the key is Action, you
* will know if you got something wrong if there is a type error in this file
*/
type ActionArgsMap = {
"seek-forward": { seconds: number } | undefined; // Args needed for seeking forward (default: 1)
"seek-backward": { seconds: number } | undefined; // Args needed for seeking backward (default: 1)
"jump-forward": { seconds: number } | undefined; // Args needed for jumping forward (default: 5)
"jump-backward": { seconds: number } | undefined; // Args needed for jumping backward (default: 5)
};
type KeysWithValueUndefined<T> = {
[K in keyof T]: undefined extends T[K] ? K : never;
}[keyof T];
/**
* Actions which require arguments for their invocation
*/
export type ActionWithArgs = keyof ActionArgsMap;
/**
* Actions which optionally takes in arguments for their invocation
*/
export type ActionWithOptionalArgs =
| ActionWithNoArgs
| KeysWithValueUndefined<ActionArgsMap>;
/**
* Actions which do not require arguments for their invocation
*/
export type ActionWithNoArgs = Exclude<Action, ActionWithArgs>;
/**
* Resolves the argument type for a given Action
*/
type ArgOfHoppAction<A extends Action> = A extends ActionWithArgs
? ActionArgsMap[A]
: undefined;
/**
* Resolves the action function for a given Action, used by action handler function defs
*/
type ActionFunc<A extends Action> = A extends ActionWithArgs
? (arg: ArgOfHoppAction<A>, trigger?: InvocationTriggers) => void
: (_?: undefined, trigger?: InvocationTriggers) => void;
type BoundActionList = {
[A in Action]?: Array<ActionFunc<A>>;
};
const boundActions: BoundActionList = {};
let currentActiveActions: Action[] = [];
function updateActiveActions() {
const newActions = Object.keys(boundActions) as Action[];
currentActiveActions = newActions;
actionEmitter.emit(newActions);
}
export function bindAction<A extends Action>(
action: A,
handler: ActionFunc<A>
) {
if (boundActions[action]) {
boundActions[action]?.push(handler);
} else {
// 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
boundActions[action] = [handler] as any;
}
updateActiveActions();
}
export type InvocationTriggers = "keypress" | "mouseclick";
type InvokeActionFunc = {
(
action: ActionWithOptionalArgs,
args?: undefined,
trigger?: InvocationTriggers
): void;
<A extends ActionWithArgs>(action: A, args: ActionArgsMap[A]): void;
};
/**
* Invokes an action, triggering action handlers if any registered.
* The second and third arguments are optional
* @param action The action to fire
* @param args The argument passed to the action handler. Optional if action has no args required
* @param trigger Optionally supply the trigger that invoked the action (keypress/mouseclick)
*/
export const invokeAction: InvokeActionFunc = <A extends Action>(
action: A,
args?: ArgOfHoppAction<A>,
trigger?: InvocationTriggers
) => {
boundActions[action]?.forEach((handler) => (handler as any)(args, trigger));
};
export function unbindAction<A extends Action>(
action: A,
handler: ActionFunc<A>
) {
// 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
boundActions[action] = boundActions[action]?.filter(
(x) => x !== handler
) as any;
if (boundActions[action]?.length === 0) {
delete boundActions[action];
}
updateActiveActions();
}
/**
* Returns whether a given action is bound at a given time
*
* @param action The action to check
*/
export function isActionBound(action: Action): boolean {
return !!boundActions[action];
}
/**
* A React hook that defines a component can handle a given
* Action. The handler will be bound when the component is mounted
* and unbound when the component is unmounted.
* @param action The action to be bound
* @param handler The function to be called when the action is invoked
* @param isActive A ref that indicates whether the action is active
*/
export function useActionHandler<A extends Action>(
action: A,
handler: ActionFunc<A>,
isActive: MutableRefObject<boolean> | boolean | undefined
) {
const handlerRef = useRef(handler);
const [isBound, setIsBound] = useState(false);
// Update handler ref when handler changes
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
// Create a stable handler wrapper
const stableHandler = useCallback(
(args: any, trigger?: InvocationTriggers) => {
(handlerRef.current as any)(args, trigger);
},
[]
) as ActionFunc<A>;
useEffect(() => {
const shouldBind =
isActive === undefined ||
(typeof isActive === "boolean" ? isActive : isActive.current);
if (shouldBind && !isBound) {
bindAction(action, stableHandler);
setIsBound(true);
} else if (!shouldBind && isBound) {
unbindAction(action, stableHandler);
setIsBound(false);
}
return () => {
if (isBound) {
unbindAction(action, stableHandler);
setIsBound(false);
}
};
}, [action, stableHandler, isActive, isBound]);
// Handle ref-based isActive changes
useEffect(() => {
if (isActive && typeof isActive === "object" && "current" in isActive) {
// Poll for ref changes
const interval = setInterval(() => {
const shouldBind = isActive.current;
if (shouldBind !== isBound) {
if (shouldBind) {
bindAction(action, stableHandler);
} else {
unbindAction(action, stableHandler);
}
setIsBound(shouldBind);
}
}, 100);
return () => clearInterval(interval);
}
}, [action, stableHandler, isActive, isBound]);
}
/**
* A React hook that returns the current list of active actions
* and re-renders when the list changes
*/
export function useActiveActions(): Action[] {
const [activeActions, setActiveActions] = useState<Action[]>([]);
useEffect(() => {
// Set initial value
setActiveActions(currentActiveActions);
// Subscribe to changes
const unsubscribe = actionEmitter.subscribe(setActiveActions);
return unsubscribe;
}, []);
return activeActions;
}
/**
* A React hook that returns whether a specific action is currently bound
* and re-renders when the binding state changes
*/
export function useIsActionBound(action: Action): boolean {
const [isBound, setIsBound] = useState(() => isActionBound(action));
useEffect(() => {
const updateBoundState = () => {
setIsBound(isActionBound(action));
};
// Set initial value
updateBoundState();
// Subscribe to changes
const unsubscribe = actionEmitter.subscribe(updateBoundState);
return unsubscribe;
}, [action]);
return isBound;
}
@@ -0,0 +1,15 @@
import type { TPlatformLayout } from "@/types/editor";
export const PLATFORM_LAYOUTS: Record<TPlatformLayout, string> = {
tiktok: "TikTok",
};
export const PANEL_CONFIG = {
panels: {
tools: 25,
preview: 50,
properties: 25,
mainContent: 70,
timeline: 30,
},
};
@@ -0,0 +1,12 @@
import type { ExportOptions } from "@/types/export";
export const DEFAULT_EXPORT_OPTIONS = {
format: "mp4",
quality: "high",
includeAudio: true,
} satisfies ExportOptions;
export const EXPORT_MIME_TYPES = {
webm: "video/webm",
mp4: "video/mp4",
} as const;
+61 -61
View File
@@ -1,66 +1,66 @@
export interface FontOption {
value: string;
label: string;
category: "system" | "google" | "custom";
weights?: number[];
hasClassName?: boolean;
value: string;
label: string;
category: "system" | "google" | "custom";
weights?: number[];
hasClassName?: boolean;
}
export const FONT_OPTIONS: FontOption[] = [
// System fonts (always available)
{ value: "Arial", label: "Arial", category: "system", hasClassName: false },
{
value: "Helvetica",
label: "Helvetica",
category: "system",
hasClassName: false,
},
{
value: "Times New Roman",
label: "Times New Roman",
category: "system",
hasClassName: false,
},
{
value: "Georgia",
label: "Georgia",
category: "system",
hasClassName: false,
},
// System fonts (always available)
{ value: "Arial", label: "Arial", category: "system", hasClassName: false },
{
value: "Helvetica",
label: "Helvetica",
category: "system",
hasClassName: false,
},
{
value: "Times New Roman",
label: "Times New Roman",
category: "system",
hasClassName: false,
},
{
value: "Georgia",
label: "Georgia",
category: "system",
hasClassName: false,
},
// Google Fonts (loaded in layout.tsx)
{
value: "Inter",
label: "Inter",
category: "google",
weights: [400, 700],
hasClassName: true,
},
{
value: "Roboto",
label: "Roboto",
category: "google",
weights: [400, 700],
hasClassName: true,
},
{
value: "Open Sans",
label: "Open Sans",
category: "google",
hasClassName: true,
},
{
value: "Playfair Display",
label: "Playfair Display",
category: "google",
hasClassName: true,
},
{
value: "Comic Neue",
label: "Comic Neue",
category: "google",
hasClassName: false,
},
// Google Fonts (loaded in layout.tsx)
{
value: "Inter",
label: "Inter",
category: "google",
weights: [400, 700],
hasClassName: true,
},
{
value: "Roboto",
label: "Roboto",
category: "google",
weights: [400, 700],
hasClassName: true,
},
{
value: "Open Sans",
label: "Open Sans",
category: "google",
hasClassName: true,
},
{
value: "Playfair Display",
label: "Playfair Display",
category: "google",
hasClassName: true,
},
{
value: "Comic Neue",
label: "Comic Neue",
category: "google",
hasClassName: false,
},
] as const;
export const DEFAULT_FONT = "Arial";
@@ -70,10 +70,10 @@ export type FontFamily = (typeof FONT_OPTIONS)[number]["value"];
// Helper functions
export const getFontByValue = (value: string): FontOption | undefined =>
FONT_OPTIONS.find((font) => font.value === value);
FONT_OPTIONS.find((font) => font.value === value);
export const getGoogleFonts = (): FontOption[] =>
FONT_OPTIONS.filter((font) => font.category === "google");
FONT_OPTIONS.filter((font) => font.category === "google");
export const getSystemFonts = (): FontOption[] =>
FONT_OPTIONS.filter((font) => font.category === "system");
FONT_OPTIONS.filter((font) => font.category === "system");
@@ -0,0 +1,11 @@
export const LANGUAGES = [
{ code: "en", name: "English" },
{ code: "es", name: "Spanish" },
{ code: "it", name: "Italian" },
{ code: "fr", name: "French" },
{ code: "de", name: "German" },
{ code: "pt", name: "Portuguese" },
{ code: "ru", name: "Russian" },
{ code: "ja", name: "Japanese" },
{ code: "zh", name: "Chinese" },
] as const;
@@ -0,0 +1,27 @@
import type { TCanvasSize } from "@/types/project";
export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] = [
{ width: 1920, height: 1080 },
{ width: 1080, height: 1920 },
{ width: 1080, height: 1080 },
{ width: 1440, height: 1080 },
];
export const FPS_PRESETS = [
{ value: "24", label: "24 fps" },
{ value: "25", label: "25 fps" },
{ value: "30", label: "30 fps" },
{ value: "60", label: "60 fps" },
{ value: "120", label: "120 fps" },
] as const;
export const BLUR_INTENSITY_PRESETS: { label: string; value: number }[] = [
{ label: "Light", value: 4 },
{ label: "Medium", value: 8 },
{ label: "Heavy", value: 18 },
] as const;
export const DEFAULT_CANVAS_SIZE: TCanvasSize = { width: 1920, height: 1080 };
export const DEFAULT_FPS = 30;
export const DEFAULT_BLUR_INTENSITY = 8;
export const DEFAULT_COLOR = "#000000";
+66
View File
@@ -0,0 +1,66 @@
import { OcDataBuddyIcon, OcMarbleIcon, } from "@opencut/ui/icons";
export const SITE_URL = "https://opencut.app";
export const SITE_INFO = {
title: "OpenCut",
description:
"A simple but powerful video editor that gets the job done. In your browser.",
url: SITE_URL,
openGraphImage: "/open-graph/default.jpg",
twitterImage: "/open-graph/default.jpg",
favicon: "/favicon.ico",
};
export type ExternalTool = {
name: string;
description: string;
url: string;
icon: React.ElementType;
};
export const EXTERNAL_TOOLS: ExternalTool[] = [
{
name: "Marble",
description:
"Modern headless CMS for content management and the blog for OpenCut",
url: "https://marblecms.com?utm_source=opencut",
icon: OcMarbleIcon,
},
{
name: "Databuddy",
description: "GDPR compliant analytics and user insights for OpenCut",
url: "https://databuddy.cc?utm_source=opencut",
icon: OcDataBuddyIcon,
},
];
export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg";
export const SOCIAL_LINKS = {
x: "https://x.com/opencutapp",
github: "https://github.com/OpenCut-app/OpenCut",
discord: "https://discord.com/invite/Mu3acKZvCp",
};
export type Sponsor = {
name: string;
url: string;
logo: string;
description: string;
};
export const SPONSORS: Sponsor[] = [
{
name: "Fal.ai",
url: "https://fal.ai?utm_source=opencut",
logo: "/logos/others/fal.svg",
description: "Generative image, video, and audio models all in one place.",
},
{
name: "Vercel",
url: "https://vercel.com?utm_source=opencut",
logo: "/logos/others/vercel.svg",
description: "Platform where we deploy and host OpenCut.",
},
];
-33
View File
@@ -1,33 +0,0 @@
export const SITE_URL = "https://opencut.app";
export const SITE_INFO = {
title: "OpenCut",
description:
"A simple but powerful video editor that gets the job done. In your browser.",
url: SITE_URL,
openGraphImage: "/open-graph/default.jpg",
twitterImage: "/open-graph/default.jpg",
favicon: "/favicon.ico",
};
export const EXTERNAL_TOOLS = [
{
name: "Marble",
description:
"Modern headless CMS for content management and the blog for OpenCut",
url: "https://marblecms.com?utm_source=opencut",
icon: "MarbleIcon" as const,
},
{
name: "Vercel",
description: "Platform where we deploy and host OpenCut",
url: "https://vercel.com?utm_source=opencut",
icon: "VercelIcon" as const,
},
{
name: "Databuddy",
description: "GDPR compliant analytics and user insights for OpenCut",
url: "https://databuddy.cc?utm_source=opencut",
icon: "DataBuddyIcon" as const,
},
];
@@ -0,0 +1,16 @@
export const STICKER_CATEGORIES = [
"all",
"general",
"brands",
"emoji",
] as const;
export const STICKER_CATEGORY_CONFIG: Record<
(typeof STICKER_CATEGORIES)[number],
string | undefined
> = {
all: undefined,
general: "General",
brands: "Brands / Social",
emoji: "Emoji",
};
+26 -24
View File
@@ -1,27 +1,29 @@
import { TextElement } from "@/types/timeline";
import type { TextElement } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "./timeline-constants";
export const DEFAULT_TEXT_ELEMENT: Omit<
TextElement,
"id"
> = {
type: "text",
name: "Text",
content: "Default Text",
fontSize: 48,
fontFamily: "Arial",
color: "#ffffff",
backgroundColor: "transparent",
textAlign: "center",
fontWeight: "normal",
fontStyle: "normal",
textDecoration: "none",
x: 0,
y: 0,
rotation: 0,
opacity: 1,
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
type: "text",
name: "Text",
content: "Default Text",
fontSize: 48,
fontFamily: "Arial",
color: "#ffffff",
backgroundColor: "transparent",
textAlign: "center",
fontWeight: "normal",
fontStyle: "normal",
textDecoration: "none",
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
transform: {
scale: 1,
position: {
x: 0,
y: 0,
},
rotate: 0,
},
opacity: 1,
};
@@ -1,107 +0,0 @@
import type { TrackType } from "@/types/timeline";
// Track color definitions
export const TRACK_COLORS: Record<
TrackType,
{ solid: string; background: string; border: string }
> = {
media: {
solid: "bg-blue-500",
background: "",
border: "",
},
text: {
solid: "bg-[#5DBAA0]",
background: "bg-[#5DBAA0]",
border: "",
},
audio: {
solid: "bg-green-500",
background: "bg-[#915DBE]",
border: "",
},
} as const;
// Utility functions
export function getTrackColors(type: TrackType) {
return TRACK_COLORS[type];
}
export function getTrackElementClasses(type: TrackType) {
const colors = getTrackColors(type);
return `${colors.background} ${colors.border}`;
}
// Track height definitions
export const TRACK_HEIGHTS: Record<TrackType, number> = {
media: 60,
text: 25,
audio: 50,
} as const;
// Utility function for track heights
export function getTrackHeight(type: TrackType): number {
return TRACK_HEIGHTS[type];
}
// Calculate cumulative height up to (but not including) a track index
export function getCumulativeHeightBefore(
tracks: Array<{ type: TrackType }>,
trackIndex: number
): number {
const GAP = 4; // 4px gap between tracks (equivalent to Tailwind's gap-1)
return tracks
.slice(0, trackIndex)
.reduce((sum, track) => sum + getTrackHeight(track.type) + GAP, 0);
}
// Calculate total height of all tracks
export function getTotalTracksHeight(
tracks: Array<{ type: TrackType }>
): number {
const GAP = 4; // 4px gap between tracks (equivalent to Tailwind's gap-1)
const tracksHeight = tracks.reduce(
(sum, track) => sum + getTrackHeight(track.type),
0
);
const gapsHeight = Math.max(0, tracks.length - 1) * GAP; // n-1 gaps for n tracks
return tracksHeight + gapsHeight;
}
// Other timeline constants
export const TIMELINE_CONSTANTS = {
ELEMENT_MIN_WIDTH: 80,
PIXELS_PER_SECOND: 50,
TRACK_HEIGHT: 60, // Default fallback
DEFAULT_TEXT_DURATION: 5,
DEFAULT_IMAGE_DURATION: 5,
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
} as const;
// FPS presets for project settings
export const FPS_PRESETS = [
{ value: "24", label: "24 fps" },
{ value: "25", label: "25 fps" },
{ value: "30", label: "30 fps" },
{ value: "60", label: "60 fps" },
{ value: "120", label: "120 fps" },
] as const;
// Frame snapping utilities
export function timeToFrame(time: number, fps: number): number {
return Math.round(time * fps);
}
export function frameToTime(frame: number, fps: number): number {
return frame / fps;
}
export function snapTimeToFrame(time: number, fps: number): number {
if (fps <= 0) return time; // Fallback for invalid FPS
const frame = timeToFrame(time, fps);
return frameToTime(frame, fps);
}
export function getFrameDuration(fps: number): number {
return 1 / fps;
}
@@ -0,0 +1,72 @@
import type { TTimelineViewState } from "@/types/project";
import type { TrackType } from "@/types/timeline";
import {
Happy01Icon,
MusicNote03Icon,
TextIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { OcVideoIcon } from "@opencut/ui/icons";
export const TRACK_COLORS: Record<TrackType, { background: string }> = {
video: {
background: "transparent",
},
text: {
background: "bg-[#5DBAA0]",
},
audio: {
background: "bg-[#915DBE]",
},
sticker: {
background: "bg-amber-500",
},
} as const;
export const TRACK_HEIGHTS: Record<TrackType, number> = {
video: 60,
text: 25,
audio: 50,
sticker: 50,
} as const;
export const TRACK_GAP = 4;
export const TIMELINE_CONSTANTS = {
PIXELS_PER_SECOND: 50,
DEFAULT_ELEMENT_DURATION: 5,
PLAYHEAD_LOOKAHEAD_SECONDS: 30, // padding ahead
PADDING_TOP_PX: 0,
ZOOM_LEVELS: [0.1, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 10, 15, 20, 30, 50],
ZOOM_MIN: 0.1,
ZOOM_MAX: 100,
ZOOM_STEP: 0.1,
} as const;
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState = {
zoomLevel: 1,
scrollLeft: 0,
playheadTime: 0,
};
export const TRACK_ICONS: Record<TrackType, React.ReactNode> = {
video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
text: (
<HugeiconsIcon
icon={TextIcon}
className="text-muted-foreground size-4 shrink-0"
/>
),
audio: (
<HugeiconsIcon
icon={MusicNote03Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
sticker: (
<HugeiconsIcon
icon={Happy01Icon}
className="text-muted-foreground size-4 shrink-0"
/>
),
} as const;
@@ -0,0 +1,58 @@
import { LANGUAGES } from "@/constants/language-constants";
import type {
TranscriptionModel,
TranscriptionModelId,
} from "@/types/transcription";
import type { LanguageCode } from "@/types/language";
const SUPPORTED_TRANSCRIPTION_LANGS: ReadonlyArray<LanguageCode> = [
"en",
"es",
"it",
"fr",
"de",
"pt",
"ru",
"ja",
"zh",
];
export const TRANSCRIPTION_LANGUAGES = LANGUAGES.filter((language) =>
SUPPORTED_TRANSCRIPTION_LANGS.includes(language.code),
);
export const TRANSCRIPTION_MODELS: TranscriptionModel[] = [
{
id: "whisper-tiny",
name: "Tiny",
huggingFaceId: "onnx-community/whisper-tiny",
description: "Fastest, lower accuracy",
},
{
id: "whisper-small",
name: "Small",
huggingFaceId: "onnx-community/whisper-small",
description: "Good balance of speed and accuracy",
},
{
id: "whisper-medium",
name: "Medium",
huggingFaceId: "onnx-community/whisper-medium",
description: "Higher accuracy, slower",
},
{
id: "whisper-large-v3-turbo",
name: "Large v3 Turbo",
huggingFaceId: "onnx-community/whisper-large-v3-turbo",
description: "Best accuracy, requires WebGPU for good performance",
},
];
export const DEFAULT_TRANSCRIPTION_MODEL: TranscriptionModelId =
"whisper-small";
export const DEFAULT_CHUNK_LENGTH_SECONDS = 30;
export const DEFAULT_STRIDE_SECONDS = 5;
export const DEFAULT_WORDS_PER_CAPTION = 3;
export const MIN_CAPTION_DURATION_SECONDS = 0.8;