refactor: move packages sub-folders into web app

This commit is contained in:
Maze Winther 2026-03-29 14:15:21 +02:00
parent 585e28d49b
commit 220ec757d4
25 changed files with 3136 additions and 3235 deletions

View File

@ -14,14 +14,9 @@ COPY bun.lock bun.lock
COPY turbo.json turbo.json COPY turbo.json turbo.json
COPY apps/web/package.json apps/web/package.json COPY apps/web/package.json apps/web/package.json
COPY packages/env/package.json packages/env/package.json
COPY packages/ui/package.json packages/ui/package.json
RUN bun install RUN bun install
COPY apps/web/ apps/web/ COPY apps/web/ apps/web/
COPY packages/env/ packages/env/
COPY packages/ui/ packages/ui/
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1

View File

@ -1,23 +1,23 @@
import type { Config } from "drizzle-kit"; import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv"; import * as dotenv from "dotenv";
import { webEnv } from "@opencut/env/web"; import { webEnv } from "@/lib/env/web";
// Load the right env file based on environment // Load the right env file based on environment
if (webEnv.NODE_ENV === "production") { if (webEnv.NODE_ENV === "production") {
dotenv.config({ path: ".env.production" }); dotenv.config({ path: ".env.production" });
} else { } else {
dotenv.config({ path: ".env.local" }); dotenv.config({ path: ".env.local" });
} }
export default { export default {
schema: "./src/schema.ts", schema: "./src/schema.ts",
dialect: "postgresql", dialect: "postgresql",
migrations: { migrations: {
table: "drizzle_migrations", table: "drizzle_migrations",
}, },
dbCredentials: { dbCredentials: {
url: webEnv.DATABASE_URL, url: webEnv.DATABASE_URL,
}, },
out: "./migrations", out: "./migrations",
strict: webEnv.NODE_ENV === "production", strict: webEnv.NODE_ENV === "production",
} satisfies Config; } satisfies Config;

View File

@ -22,8 +22,6 @@
"@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6", "@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1", "@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@opennextjs/cloudflare": "^1.18.0", "@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",

View File

@ -1,280 +1,280 @@
import { webEnv } from "@opencut/env/web"; import { webEnv } from "@/lib/env/web";
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit"; import { checkRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({ const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(), q: z.string().max(500, "Query too long").optional(),
type: z.enum(["songs", "effects"]).optional(), type: z.enum(["songs", "effects"]).optional(),
page: z.coerce.number().int().min(1).max(1000).default(1), page: z.coerce.number().int().min(1).max(1000).default(1),
page_size: z.coerce.number().int().min(1).max(150).default(20), page_size: z.coerce.number().int().min(1).max(150).default(20),
sort: z sort: z
.enum(["downloads", "rating", "created", "score"]) .enum(["downloads", "rating", "created", "score"])
.default("downloads"), .default("downloads"),
min_rating: z.coerce.number().min(0).max(5).default(3), min_rating: z.coerce.number().min(0).max(5).default(3),
commercial_only: z.coerce.boolean().default(true), commercial_only: z.coerce.boolean().default(true),
}); });
const freesoundResultSchema = z.object({ const freesoundResultSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
url: z.string().url(), url: z.string().url(),
previews: z previews: z
.object({ .object({
"preview-hq-mp3": z.string().url(), "preview-hq-mp3": z.string().url(),
"preview-lq-mp3": z.string().url(), "preview-lq-mp3": z.string().url(),
"preview-hq-ogg": z.string().url(), "preview-hq-ogg": z.string().url(),
"preview-lq-ogg": z.string().url(), "preview-lq-ogg": z.string().url(),
}) })
.optional(), .optional(),
download: z.string().url().optional(), download: z.string().url().optional(),
duration: z.number(), duration: z.number(),
filesize: z.number(), filesize: z.number(),
type: z.string(), type: z.string(),
channels: z.number(), channels: z.number(),
bitrate: z.number(), bitrate: z.number(),
bitdepth: z.number(), bitdepth: z.number(),
samplerate: z.number(), samplerate: z.number(),
username: z.string(), username: z.string(),
tags: z.array(z.string()), tags: z.array(z.string()),
license: z.string(), license: z.string(),
created: z.string(), created: z.string(),
num_downloads: z.number().optional(), num_downloads: z.number().optional(),
avg_rating: z.number().optional(), avg_rating: z.number().optional(),
num_ratings: z.number().optional(), num_ratings: z.number().optional(),
}); });
const freesoundResponseSchema = z.object({ const freesoundResponseSchema = z.object({
count: z.number(), count: z.number(),
next: z.string().url().nullable(), next: z.string().url().nullable(),
previous: z.string().url().nullable(), previous: z.string().url().nullable(),
results: z.array(freesoundResultSchema), results: z.array(freesoundResultSchema),
}); });
const transformedResultSchema = z.object({ const transformedResultSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
url: z.string(), url: z.string(),
previewUrl: z.string().optional(), previewUrl: z.string().optional(),
downloadUrl: z.string().optional(), downloadUrl: z.string().optional(),
duration: z.number(), duration: z.number(),
filesize: z.number(), filesize: z.number(),
type: z.string(), type: z.string(),
channels: z.number(), channels: z.number(),
bitrate: z.number(), bitrate: z.number(),
bitdepth: z.number(), bitdepth: z.number(),
samplerate: z.number(), samplerate: z.number(),
username: z.string(), username: z.string(),
tags: z.array(z.string()), tags: z.array(z.string()),
license: z.string(), license: z.string(),
created: z.string(), created: z.string(),
downloads: z.number().optional(), downloads: z.number().optional(),
rating: z.number().optional(), rating: z.number().optional(),
ratingCount: z.number().optional(), ratingCount: z.number().optional(),
}); });
const apiResponseSchema = z.object({ const apiResponseSchema = z.object({
count: z.number(), count: z.number(),
next: z.string().nullable(), next: z.string().nullable(),
previous: z.string().nullable(), previous: z.string().nullable(),
results: z.array(transformedResultSchema), results: z.array(transformedResultSchema),
query: z.string().optional(), query: z.string().optional(),
type: z.string(), type: z.string(),
page: z.number(), page: z.number(),
pageSize: z.number(), pageSize: z.number(),
sort: z.string(), sort: z.string(),
minRating: z.number().optional(), minRating: z.number().optional(),
}); });
function buildSortParameter({ query, sort }: { query?: string; sort: string }) { function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
if (!query) return `${sort}_desc`; if (!query) return `${sort}_desc`;
return sort === "score" ? "score" : `${sort}_desc`; return sort === "score" ? "score" : `${sort}_desc`;
} }
function applyEffectsFilters({ function applyEffectsFilters({
params, params,
min_rating, min_rating,
commercial_only, commercial_only,
}: { }: {
params: URLSearchParams; params: URLSearchParams;
min_rating: number; min_rating: number;
commercial_only: boolean; commercial_only: boolean;
}) { }) {
params.append("filter", "duration:[* TO 30.0]"); params.append("filter", "duration:[* TO 30.0]");
params.append("filter", `avg_rating:[${min_rating} TO *]`); params.append("filter", `avg_rating:[${min_rating} TO *]`);
if (commercial_only) { if (commercial_only) {
params.append( params.append(
"filter", "filter",
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")', 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
); );
} }
params.append( params.append(
"filter", "filter",
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion", "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
); );
} }
function transformFreesoundResult( function transformFreesoundResult(
result: z.infer<typeof freesoundResultSchema>, result: z.infer<typeof freesoundResultSchema>,
) { ) {
return { return {
id: result.id, id: result.id,
name: result.name, name: result.name,
description: result.description, description: result.description,
url: result.url, url: result.url,
previewUrl: previewUrl:
result.previews?.["preview-hq-mp3"] || result.previews?.["preview-hq-mp3"] ||
result.previews?.["preview-lq-mp3"], result.previews?.["preview-lq-mp3"],
downloadUrl: result.download, downloadUrl: result.download,
duration: result.duration, duration: result.duration,
filesize: result.filesize, filesize: result.filesize,
type: result.type, type: result.type,
channels: result.channels, channels: result.channels,
bitrate: result.bitrate, bitrate: result.bitrate,
bitdepth: result.bitdepth, bitdepth: result.bitdepth,
samplerate: result.samplerate, samplerate: result.samplerate,
username: result.username, username: result.username,
tags: result.tags, tags: result.tags,
license: result.license, license: result.license,
created: result.created, created: result.created,
downloads: result.num_downloads || 0, downloads: result.num_downloads || 0,
rating: result.avg_rating || 0, rating: result.avg_rating || 0,
ratingCount: result.num_ratings || 0, ratingCount: result.num_ratings || 0,
}; };
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { limited } = await checkRateLimit({ request }); const { limited } = await checkRateLimit({ request });
if (limited) { if (limited) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 }); return NextResponse.json({ error: "Too many requests" }, { status: 429 });
} }
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const validationResult = searchParamsSchema.safeParse({ const validationResult = searchParamsSchema.safeParse({
q: searchParams.get("q") || undefined, q: searchParams.get("q") || undefined,
type: searchParams.get("type") || undefined, type: searchParams.get("type") || undefined,
page: searchParams.get("page") || undefined, page: searchParams.get("page") || undefined,
page_size: searchParams.get("page_size") || undefined, page_size: searchParams.get("page_size") || undefined,
sort: searchParams.get("sort") || undefined, sort: searchParams.get("sort") || undefined,
min_rating: searchParams.get("min_rating") || undefined, min_rating: searchParams.get("min_rating") || undefined,
}); });
if (!validationResult.success) { if (!validationResult.success) {
return NextResponse.json( return NextResponse.json(
{ {
error: "Invalid parameters", error: "Invalid parameters",
details: validationResult.error.flatten().fieldErrors, details: validationResult.error.flatten().fieldErrors,
}, },
{ status: 400 }, { status: 400 },
); );
} }
const { const {
q: query, q: query,
type, type,
page, page,
page_size: pageSize, page_size: pageSize,
sort, sort,
min_rating, min_rating,
commercial_only, commercial_only,
} = validationResult.data; } = validationResult.data;
if (type === "songs") { if (type === "songs") {
return NextResponse.json( return NextResponse.json(
{ {
error: "Songs are not available yet", error: "Songs are not available yet",
message: message:
"Song search functionality is coming soon. Try searching for sound effects instead.", "Song search functionality is coming soon. Try searching for sound effects instead.",
}, },
{ status: 501 }, { status: 501 },
); );
} }
const baseUrl = "https://freesound.org/apiv2/search/text/"; const baseUrl = "https://freesound.org/apiv2/search/text/";
const sortParam = buildSortParameter({ query, sort }); const sortParam = buildSortParameter({ query, sort });
const params = new URLSearchParams({ const params = new URLSearchParams({
query: query || "", query: query || "",
token: webEnv.FREESOUND_API_KEY, token: webEnv.FREESOUND_API_KEY,
page: page.toString(), page: page.toString(),
page_size: pageSize.toString(), page_size: pageSize.toString(),
sort: sortParam, sort: sortParam,
fields: fields:
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings", "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
}); });
const isEffectsSearch = type === "effects" || !type; const isEffectsSearch = type === "effects" || !type;
if (isEffectsSearch) { if (isEffectsSearch) {
applyEffectsFilters({ params, min_rating, commercial_only }); applyEffectsFilters({ params, min_rating, commercial_only });
} }
const response = await fetch(`${baseUrl}?${params.toString()}`); const response = await fetch(`${baseUrl}?${params.toString()}`);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error("Freesound API error:", response.status, errorText); console.error("Freesound API error:", response.status, errorText);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to search sounds" }, { error: "Failed to search sounds" },
{ status: response.status }, { status: response.status },
); );
} }
const rawData = await response.json(); const rawData = await response.json();
const freesoundValidation = freesoundResponseSchema.safeParse(rawData); const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
if (!freesoundValidation.success) { if (!freesoundValidation.success) {
console.error( console.error(
"Invalid Freesound API response:", "Invalid Freesound API response:",
freesoundValidation.error, freesoundValidation.error,
); );
return NextResponse.json( return NextResponse.json(
{ error: "Invalid response from Freesound API" }, { error: "Invalid response from Freesound API" },
{ status: 502 }, { status: 502 },
); );
} }
const data = freesoundValidation.data; const data = freesoundValidation.data;
const transformedResults = data.results.map(transformFreesoundResult); const transformedResults = data.results.map(transformFreesoundResult);
const responseData = { const responseData = {
count: data.count, count: data.count,
next: data.next, next: data.next,
previous: data.previous, previous: data.previous,
results: transformedResults, results: transformedResults,
query: query || "", query: query || "",
type: type || "effects", type: type || "effects",
page, page,
pageSize, pageSize,
sort, sort,
minRating: min_rating, minRating: min_rating,
}; };
const responseValidation = apiResponseSchema.safeParse(responseData); const responseValidation = apiResponseSchema.safeParse(responseData);
if (!responseValidation.success) { if (!responseValidation.success) {
console.error( console.error(
"Invalid API response structure:", "Invalid API response structure:",
responseValidation.error, responseValidation.error,
); );
return NextResponse.json( return NextResponse.json(
{ error: "Internal response formatting error" }, { error: "Internal response formatting error" },
{ status: 500 }, { status: 500 },
); );
} }
return NextResponse.json(responseValidation.data); return NextResponse.json(responseValidation.data);
} catch (error) { } catch (error) {
console.error("Error searching sounds:", error); console.error("Error searching sounds:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },
{ status: 500 }, { status: 500 },
); );
} }
} }

View File

@ -5,7 +5,7 @@ import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip"; import { TooltipProvider } from "../components/ui/tooltip";
import { baseMetaData } from "./metadata"; import { baseMetaData } from "./metadata";
import { BotIdClient } from "botid/client"; import { BotIdClient } from "botid/client";
import { webEnv } from "@opencut/env/web"; import { webEnv } from "@/lib/env/web";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
const siteFont = Inter({ subsets: ["latin"] }); const siteFont = Inter({ subsets: ["latin"] });

File diff suppressed because it is too large Load Diff

View File

@ -1,369 +1,369 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { FPS_PRESETS } from "@/constants/project-constants"; import { FPS_PRESETS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { import {
Section, Section,
SectionContent, SectionContent,
SectionHeader, SectionHeader,
SectionTitle, SectionTitle,
} from "@/components/section"; } from "@/components/section";
import { BackgroundContent } from "./background"; import { BackgroundContent } from "./background";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { NumberField } from "@/components/ui/number-field"; import { NumberField } from "@/components/ui/number-field";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { Tick02Icon } from "@hugeicons/core-free-icons"; import { Tick02Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import { dimensionToAspectRatio } from "@/utils/geometry"; import { dimensionToAspectRatio } from "@/utils/geometry";
import { formatNumberForDisplay } from "@/utils/math"; import { formatNumberForDisplay } from "@/utils/math";
import { OcSquarePlusIcon } from "@opencut/ui/icons"; import { OcSquarePlusIcon } from "@/components/icons";
import type { TCanvasSize } from "@/lib/project/types"; import type { TCanvasSize } from "@/lib/project/types";
type SettingsView = "project-info" | "background"; type SettingsView = "project-info" | "background";
const PRESET_LABELS: Record<string, string> = { const PRESET_LABELS: Record<string, string> = {
"1:1": "1:1", "1:1": "1:1",
"16:9": "16:9", "16:9": "16:9",
"9:16": "9:16", "9:16": "9:16",
"4:3": "4:3", "4:3": "4:3",
}; };
function areCanvasSizesEqual({ function areCanvasSizesEqual({
left, left,
right, right,
}: { }: {
left: TCanvasSize; left: TCanvasSize;
right: TCanvasSize; right: TCanvasSize;
}) { }) {
return left.width === right.width && left.height === right.height; return left.width === right.width && left.height === right.height;
} }
function formatCanvasDimension({ value }: { value: number }) { function formatCanvasDimension({ value }: { value: number }) {
return formatNumberForDisplay({ value, maxFractionDigits: 0 }); return formatNumberForDisplay({ value, maxFractionDigits: 0 });
} }
function parseCanvasDimension({ input }: { input: string }): number | null { function parseCanvasDimension({ input }: { input: string }): number | null {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) return null; if (!trimmed) return null;
const parsed = Number(trimmed); const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) return null; if (!Number.isFinite(parsed)) return null;
const rounded = Math.round(parsed); const rounded = Math.round(parsed);
return rounded > 0 ? rounded : null; return rounded > 0 ? rounded : null;
} }
function useCanvasDimensionDraft({ function useCanvasDimensionDraft({
value, value,
onCommit, onCommit,
}: { }: {
value: number; value: number;
onCommit: (value: number) => void; onCommit: (value: number) => void;
}) { }) {
const pendingValueRef = useRef(value); const pendingValueRef = useRef(value);
const syncedValueRef = useRef(value); const syncedValueRef = useRef(value);
if (syncedValueRef.current !== value) { if (syncedValueRef.current !== value) {
syncedValueRef.current = value; syncedValueRef.current = value;
pendingValueRef.current = value; pendingValueRef.current = value;
} }
return usePropertyDraft({ return usePropertyDraft({
displayValue: formatCanvasDimension({ value }), displayValue: formatCanvasDimension({ value }),
parse: (input) => parseCanvasDimension({ input }), parse: (input) => parseCanvasDimension({ input }),
onPreview: (nextValue) => { onPreview: (nextValue) => {
pendingValueRef.current = nextValue; pendingValueRef.current = nextValue;
}, },
onCommit: () => { onCommit: () => {
if (pendingValueRef.current !== value) { if (pendingValueRef.current !== value) {
onCommit(pendingValueRef.current); onCommit(pendingValueRef.current);
} }
}, },
}); });
} }
export function SettingsView() { export function SettingsView() {
const [view, setView] = useState<SettingsView>("project-info"); const [view, setView] = useState<SettingsView>("project-info");
const editor = useEditor(); const editor = useEditor();
const activeProject = useEditor((e) => e.project.getActive()); const activeProject = useEditor((e) => e.project.getActive());
const { canvasPresets } = useEditorStore(); const { canvasPresets } = useEditorStore();
const currentCanvasSize = activeProject.settings.canvasSize; const currentCanvasSize = activeProject.settings.canvasSize;
const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset"; const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
const lastCustomCanvasSize = const lastCustomCanvasSize =
activeProject.settings.lastCustomCanvasSize ?? null; activeProject.settings.lastCustomCanvasSize ?? null;
const presetItems = canvasPresets.map((preset, index) => { const presetItems = canvasPresets.map((preset, index) => {
const ratio = dimensionToAspectRatio(preset); const ratio = dimensionToAspectRatio(preset);
return { return {
id: index.toString(), id: index.toString(),
label: PRESET_LABELS[ratio] ?? ratio, label: PRESET_LABELS[ratio] ?? ratio,
ratio, ratio,
canvasSize: preset, canvasSize: preset,
}; };
}); });
const selectedPresetId = canvasSizeMode === "preset" const selectedPresetId = canvasSizeMode === "preset"
? (presetItems.find((preset) => ? (presetItems.find((preset) =>
areCanvasSizesEqual({ areCanvasSizesEqual({
left: preset.canvasSize, left: preset.canvasSize,
right: currentCanvasSize, right: currentCanvasSize,
}), }),
)?.id ?? null) )?.id ?? null)
: null; : null;
const updateCustomCanvasSize = ({ const updateCustomCanvasSize = ({
canvasSize, canvasSize,
}: { }: {
canvasSize: TCanvasSize; canvasSize: TCanvasSize;
}) => { }) => {
const shouldUpdateCanvasSize = !areCanvasSizesEqual({ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
left: canvasSize, left: canvasSize,
right: currentCanvasSize, right: currentCanvasSize,
}); });
const shouldUpdateLastCustomCanvasSize = const shouldUpdateLastCustomCanvasSize =
lastCustomCanvasSize === null || lastCustomCanvasSize === null ||
!areCanvasSizesEqual({ !areCanvasSizesEqual({
left: canvasSize, left: canvasSize,
right: lastCustomCanvasSize, right: lastCustomCanvasSize,
}); });
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom"; const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
if ( if (
!shouldUpdateCanvasSize && !shouldUpdateCanvasSize &&
!shouldUpdateLastCustomCanvasSize && !shouldUpdateLastCustomCanvasSize &&
!shouldUpdateCanvasSizeMode !shouldUpdateCanvasSizeMode
) { ) {
return; return;
} }
editor.project.updateSettings({ editor.project.updateSettings({
settings: { settings: {
...(shouldUpdateCanvasSize ? { canvasSize } : {}), ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
...(shouldUpdateCanvasSizeMode ...(shouldUpdateCanvasSizeMode
? { canvasSizeMode: "custom" as const } ? { canvasSizeMode: "custom" as const }
: {}), : {}),
lastCustomCanvasSize: canvasSize, lastCustomCanvasSize: canvasSize,
}, },
}); });
}; };
const selectPresetCanvasSize = ({ const selectPresetCanvasSize = ({
canvasSize, canvasSize,
}: { }: {
canvasSize: TCanvasSize; canvasSize: TCanvasSize;
}) => { }) => {
const shouldUpdateCanvasSize = !areCanvasSizesEqual({ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
left: canvasSize, left: canvasSize,
right: currentCanvasSize, right: currentCanvasSize,
}); });
const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset"; const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return; if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
editor.project.updateSettings({ editor.project.updateSettings({
settings: { settings: {
...(shouldUpdateCanvasSize ? { canvasSize } : {}), ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
...(shouldUpdateCanvasSizeMode ...(shouldUpdateCanvasSizeMode
? { canvasSizeMode: "preset" as const } ? { canvasSizeMode: "preset" as const }
: {}), : {}),
}, },
}); });
}; };
const selectCustomCanvasSize = () => { const selectCustomCanvasSize = () => {
updateCustomCanvasSize({ updateCustomCanvasSize({
canvasSize: lastCustomCanvasSize ?? currentCanvasSize, canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
}); });
}; };
const widthDraft = useCanvasDimensionDraft({ const widthDraft = useCanvasDimensionDraft({
value: currentCanvasSize.width, value: currentCanvasSize.width,
onCommit: (width) => onCommit: (width) =>
updateCustomCanvasSize({ updateCustomCanvasSize({
canvasSize: { width, height: currentCanvasSize.height }, canvasSize: { width, height: currentCanvasSize.height },
}), }),
}); });
const heightDraft = useCanvasDimensionDraft({ const heightDraft = useCanvasDimensionDraft({
value: currentCanvasSize.height, value: currentCanvasSize.height,
onCommit: (height) => onCommit: (height) =>
updateCustomCanvasSize({ updateCustomCanvasSize({
canvasSize: { width: currentCanvasSize.width, height }, canvasSize: { width: currentCanvasSize.width, height },
}), }),
}); });
const isCustomSelected = canvasSizeMode === "custom"; const isCustomSelected = canvasSizeMode === "custom";
return ( return (
<PanelView <PanelView
contentClassName="px-0" contentClassName="px-0"
scrollClassName="pt-0" scrollClassName="pt-0"
actions={ actions={
<Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}> <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
<TabsList> <TabsList>
<TabsTrigger value="project-info">Project info</TabsTrigger> <TabsTrigger value="project-info">Project info</TabsTrigger>
<TabsTrigger value="background">Background</TabsTrigger> <TabsTrigger value="background">Background</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
} }
> >
{view === "project-info" && ( {view === "project-info" && (
<div className="flex flex-col"> <div className="flex flex-col">
<Section showTopBorder={false}> <Section showTopBorder={false}>
<SectionHeader> <SectionHeader>
<SectionTitle className="flex-1">Name</SectionTitle> <SectionTitle className="flex-1">Name</SectionTitle>
<span className="text-sm truncate"> <span className="text-sm truncate">
{activeProject.metadata.name} {activeProject.metadata.name}
</span> </span>
</SectionHeader> </SectionHeader>
</Section> </Section>
<Section showTopBorder={false}> <Section showTopBorder={false}>
<SectionHeader className="justify-between"> <SectionHeader className="justify-between">
<SectionTitle className="flex-1">Frame rate</SectionTitle> <SectionTitle className="flex-1">Frame rate</SectionTitle>
<Select <Select
value={activeProject.settings.fps.toString()} value={activeProject.settings.fps.toString()}
onValueChange={(value) => { onValueChange={(value) => {
const fps = parseFloat(value); const fps = parseFloat(value);
editor.project.updateSettings({ settings: { fps } }); editor.project.updateSettings({ settings: { fps } });
}} }}
> >
<SelectTrigger className="bg-transparent border-none p-1 h-auto"> <SelectTrigger className="bg-transparent border-none p-1 h-auto">
<SelectValue placeholder="Select a frame rate" /> <SelectValue placeholder="Select a frame rate" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FPS_PRESETS.map((preset) => ( {FPS_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}> <SelectItem key={preset.value} value={preset.value}>
{preset.label} {preset.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</SectionHeader> </SectionHeader>
</Section> </Section>
<Section <Section
showTopBorder={false} showTopBorder={false}
collapsible collapsible
sectionKey="settings:aspect-ratio" sectionKey="settings:aspect-ratio"
> >
<SectionHeader> <SectionHeader>
<SectionTitle className="flex-1">Aspect ratio</SectionTitle> <SectionTitle className="flex-1">Aspect ratio</SectionTitle>
</SectionHeader> </SectionHeader>
<SectionContent className="px-2 flex flex-col gap-1 pb-2"> <SectionContent className="px-2 flex flex-col gap-1 pb-2">
{presetItems.map((preset) => ( {presetItems.map((preset) => (
<AspectRatioItem <AspectRatioItem
key={preset.id} key={preset.id}
label={preset.label} label={preset.label}
previewIcon={<AspectRatioPreview ratio={preset.ratio} />} previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
isSelected={selectedPresetId === preset.id} isSelected={selectedPresetId === preset.id}
onClick={() => { onClick={() => {
selectPresetCanvasSize({ selectPresetCanvasSize({
canvasSize: preset.canvasSize, canvasSize: preset.canvasSize,
}); });
}} }}
/> />
))} ))}
<div className="pb-2"> <div className="pb-2">
<AspectRatioItem <AspectRatioItem
key="custom" key="custom"
label="Custom" label="Custom"
previewIcon={<OcSquarePlusIcon />} previewIcon={<OcSquarePlusIcon />}
isSelected={isCustomSelected} isSelected={isCustomSelected}
onClick={selectCustomCanvasSize} onClick={selectCustomCanvasSize}
uiOptions={ uiOptions={
<div className=" flex items-center gap-2 text-foreground"> <div className=" flex items-center gap-2 text-foreground">
<NumberField <NumberField
value={widthDraft.displayValue} value={widthDraft.displayValue}
className="w-full" className="w-full"
aria-label="Canvas width" aria-label="Canvas width"
onFocus={widthDraft.onFocus} onFocus={widthDraft.onFocus}
onChange={widthDraft.onChange} onChange={widthDraft.onChange}
onBlur={widthDraft.onBlur} onBlur={widthDraft.onBlur}
/> />
<NumberField <NumberField
value={heightDraft.displayValue} value={heightDraft.displayValue}
className="w-full" className="w-full"
aria-label="Canvas height" aria-label="Canvas height"
onFocus={heightDraft.onFocus} onFocus={heightDraft.onFocus}
onChange={heightDraft.onChange} onChange={heightDraft.onChange}
onBlur={heightDraft.onBlur} onBlur={heightDraft.onBlur}
/> />
</div> </div>
} }
/> />
</div> </div>
</SectionContent> </SectionContent>
</Section> </Section>
</div> </div>
)} )}
{view === "background" && <BackgroundContent />} {view === "background" && <BackgroundContent />}
</PanelView> </PanelView>
); );
} }
function AspectRatioItem({ function AspectRatioItem({
label, label,
previewIcon, previewIcon,
isSelected, isSelected,
onClick, onClick,
uiOptions, uiOptions,
}: { }: {
label: string; label: string;
previewIcon: React.ReactNode; previewIcon: React.ReactNode;
isSelected: boolean; isSelected: boolean;
onClick: () => void; onClick: () => void;
uiOptions?: React.ReactNode; uiOptions?: React.ReactNode;
}) { }) {
return ( return (
<Button <Button
variant={isSelected ? "secondary" : "ghost"} variant={isSelected ? "secondary" : "ghost"}
className={cn( className={cn(
"px-2 py-0 flex flex-col h-fit w-full", "px-2 py-0 flex flex-col h-fit w-full",
!isSelected && "border border-transparent opacity-75!", !isSelected && "border border-transparent opacity-75!",
)} )}
onClick={onClick} onClick={onClick}
> >
<div className="w-full flex justify-between items-center h-8"> <div className="w-full flex justify-between items-center h-8">
<div className="flex-1 flex items-center gap-2"> <div className="flex-1 flex items-center gap-2">
<div className="flex items-center justify-center size-5"> <div className="flex items-center justify-center size-5">
{previewIcon} {previewIcon}
</div> </div>
<span className="text-sm truncate">{label}</span> <span className="text-sm truncate">{label}</span>
</div> </div>
<div> <div>
{isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />} {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
</div> </div>
</div> </div>
{uiOptions && isSelected && ( {uiOptions && isSelected && (
<div className="w-full pb-2">{uiOptions}</div> <div className="w-full pb-2">{uiOptions}</div>
)} )}
</Button> </Button>
); );
} }
function AspectRatioPreview({ ratio }: { ratio?: string }) { function AspectRatioPreview({ ratio }: { ratio?: string }) {
if (!ratio) return null; if (!ratio) return null;
const [w, h] = ratio.split(":").map(Number); const [w, h] = ratio.split(":").map(Number);
const maxSize = 16; const maxSize = 16;
const width = w >= h ? maxSize : (w / h) * maxSize; const width = w >= h ? maxSize : (w / h) * maxSize;
const height = h >= w ? maxSize : (h / w) * maxSize; const height = h >= w ? maxSize : (h / w) * maxSize;
return ( return (
<div <div
style={{ width, height, borderWidth: 1.5 }} style={{ width, height, borderWidth: 1.5 }}
className="rounded-xs border-current opacity-60" className="rounded-xs border-current opacity-60"
/> />
); );
} }

View File

@ -1,303 +1,303 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { import type {
EffectElement, EffectElement,
GraphicElement, GraphicElement,
ImageElement, ImageElement,
MaskableElement, MaskableElement,
RetimableElement, RetimableElement,
StickerElement, StickerElement,
TextElement, TextElement,
VisualElement, VisualElement,
VideoElement, VideoElement,
AudioElement, AudioElement,
TimelineElement, TimelineElement,
} from "@/lib/timeline"; } from "@/lib/timeline";
import type { MediaAsset } from "@/lib/media/types"; import type { MediaAsset } from "@/lib/media/types";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { import {
TextFontIcon, TextFontIcon,
ArrowExpandIcon, ArrowExpandIcon,
RainDropIcon, RainDropIcon,
MusicNote03Icon, MusicNote03Icon,
MagicWand05Icon, MagicWand05Icon,
DashboardSpeed02Icon, DashboardSpeed02Icon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { TransformTab } from "./tabs/transform-tab"; import { TransformTab } from "./tabs/transform-tab";
import { BlendingTab } from "./tabs/blending-tab"; import { BlendingTab } from "./tabs/blending-tab";
import { AudioTab } from "./tabs/audio-tab"; import { AudioTab } from "./tabs/audio-tab";
import { TextTab } from "./tabs/text-tab"; import { TextTab } from "./tabs/text-tab";
import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab"; import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
import { MasksTab } from "./tabs/masks-tab"; import { MasksTab } from "./tabs/masks-tab";
import { SpeedTab } from "./tabs/speed-tab"; import { SpeedTab } from "./tabs/speed-tab";
import { GraphicTab } from "./tabs/graphic-tab"; import { GraphicTab } from "./tabs/graphic-tab";
import { OcShapesIcon } from "@opencut/ui/icons"; import { OcShapesIcon } from "@/components/icons";
export type TabContentProps = { export type TabContentProps = {
trackId: string; trackId: string;
}; };
export type PropertiesTabDef = { export type PropertiesTabDef = {
id: string; id: string;
label: string; label: string;
icon: ReactNode; icon: ReactNode;
content: (props: TabContentProps) => ReactNode; content: (props: TabContentProps) => ReactNode;
}; };
export type ElementPropertiesConfig = { export type ElementPropertiesConfig = {
defaultTab: string; defaultTab: string;
tabs: PropertiesTabDef[]; tabs: PropertiesTabDef[];
}; };
function buildTransformTab({ function buildTransformTab({
element, element,
}: { }: {
element: VisualElement; element: VisualElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "transform", id: "transform",
label: "Transform", label: "Transform",
icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />, icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
content: ({ trackId }) => ( content: ({ trackId }) => (
<TransformTab element={element} trackId={trackId} /> <TransformTab element={element} trackId={trackId} />
), ),
}; };
} }
function buildBlendingTab({ function buildBlendingTab({
element, element,
}: { }: {
element: VisualElement; element: VisualElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "blending", id: "blending",
label: "Blending", label: "Blending",
icon: <HugeiconsIcon icon={RainDropIcon} size={16} />, icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
content: ({ trackId }) => ( content: ({ trackId }) => (
<BlendingTab element={element} trackId={trackId} /> <BlendingTab element={element} trackId={trackId} />
), ),
}; };
} }
function buildAudioTab({ function buildAudioTab({
element, element,
}: { }: {
element: AudioElement | VideoElement; element: AudioElement | VideoElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "audio", id: "audio",
label: "Audio", label: "Audio",
icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />, icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />, content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
}; };
} }
function buildSpeedTab({ function buildSpeedTab({
element, element,
}: { }: {
element: RetimableElement; element: RetimableElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "speed", id: "speed",
label: "Speed", label: "Speed",
icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />, icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />, content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
}; };
} }
function buildMasksTab({ function buildMasksTab({
element, element,
}: { }: {
element: MaskableElement; element: MaskableElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "masks", id: "masks",
label: "Masks", label: "Masks",
icon: <OcShapesIcon size={16} />, icon: <OcShapesIcon size={16} />,
content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />, content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
}; };
} }
function buildClipEffectsTab({ function buildClipEffectsTab({
element, element,
}: { }: {
element: VisualElement; element: VisualElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "effects", id: "effects",
label: "Effects", label: "Effects",
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />, icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
content: ({ trackId }) => ( content: ({ trackId }) => (
<ClipEffectsTab element={element} trackId={trackId} /> <ClipEffectsTab element={element} trackId={trackId} />
), ),
}; };
} }
function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
return { return {
id: "text", id: "text",
label: "Text", label: "Text",
icon: <HugeiconsIcon icon={TextFontIcon} size={16} />, icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
content: ({ trackId }) => <TextTab element={element} trackId={trackId} />, content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
}; };
} }
function buildGraphicTab({ function buildGraphicTab({
element, element,
}: { }: {
element: GraphicElement; element: GraphicElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "graphic", id: "graphic",
label: "Graphic", label: "Graphic",
icon: <OcShapesIcon size={16} />, icon: <OcShapesIcon size={16} />,
content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />, content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
}; };
} }
function buildStandaloneEffectTab({ function buildStandaloneEffectTab({
element, element,
}: { }: {
element: EffectElement; element: EffectElement;
}): PropertiesTabDef { }): PropertiesTabDef {
return { return {
id: "effects", id: "effects",
label: "Effects", label: "Effects",
icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />, icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
content: ({ trackId }) => ( content: ({ trackId }) => (
<StandaloneEffectTab element={element} trackId={trackId} /> <StandaloneEffectTab element={element} trackId={trackId} />
), ),
}; };
} }
function getTextConfig({ function getTextConfig({
element, element,
}: { }: {
element: TextElement; element: TextElement;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
return { return {
defaultTab: "text", defaultTab: "text",
tabs: [ tabs: [
buildTextTab({ element }), buildTextTab({ element }),
buildTransformTab({ element }), buildTransformTab({ element }),
buildBlendingTab({ element }), buildBlendingTab({ element }),
], ],
}; };
} }
function getVideoConfig({ function getVideoConfig({
element, element,
mediaAsset, mediaAsset,
}: { }: {
element: VideoElement; element: VideoElement;
mediaAsset: MediaAsset | undefined; mediaAsset: MediaAsset | undefined;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
const showAudioTab = mediaAsset?.hasAudio !== false; const showAudioTab = mediaAsset?.hasAudio !== false;
return { return {
defaultTab: "transform", defaultTab: "transform",
tabs: [ tabs: [
buildTransformTab({ element }), buildTransformTab({ element }),
...(showAudioTab ? [buildAudioTab({ element })] : []), ...(showAudioTab ? [buildAudioTab({ element })] : []),
buildSpeedTab({ element }), buildSpeedTab({ element }),
buildBlendingTab({ element }), buildBlendingTab({ element }),
buildMasksTab({ element }), buildMasksTab({ element }),
buildClipEffectsTab({ element }), buildClipEffectsTab({ element }),
], ],
}; };
} }
function getImageConfig({ function getImageConfig({
element, element,
}: { }: {
element: ImageElement; element: ImageElement;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
return { return {
defaultTab: "transform", defaultTab: "transform",
tabs: [ tabs: [
buildTransformTab({ element }), buildTransformTab({ element }),
buildBlendingTab({ element }), buildBlendingTab({ element }),
buildMasksTab({ element }), buildMasksTab({ element }),
buildClipEffectsTab({ element }), buildClipEffectsTab({ element }),
], ],
}; };
} }
function getStickerConfig({ function getStickerConfig({
element, element,
}: { }: {
element: StickerElement; element: StickerElement;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
return { return {
defaultTab: "transform", defaultTab: "transform",
tabs: [ tabs: [
buildTransformTab({ element }), buildTransformTab({ element }),
buildBlendingTab({ element }), buildBlendingTab({ element }),
buildClipEffectsTab({ element }), buildClipEffectsTab({ element }),
], ],
}; };
} }
function getGraphicConfig({ function getGraphicConfig({
element, element,
}: { }: {
element: GraphicElement; element: GraphicElement;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
return { return {
defaultTab: "graphic", defaultTab: "graphic",
tabs: [ tabs: [
buildGraphicTab({ element }), buildGraphicTab({ element }),
buildTransformTab({ element }), buildTransformTab({ element }),
buildBlendingTab({ element }), buildBlendingTab({ element }),
buildMasksTab({ element }), buildMasksTab({ element }),
buildClipEffectsTab({ element }), buildClipEffectsTab({ element }),
], ],
}; };
} }
function getAudioConfig({ function getAudioConfig({
element, element,
}: { }: {
element: AudioElement; element: AudioElement;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
return { return {
defaultTab: "audio", defaultTab: "audio",
tabs: [buildAudioTab({ element }), buildSpeedTab({ element })], tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
}; };
} }
function getEffectConfig({ function getEffectConfig({
element, element,
}: { }: {
element: EffectElement; element: EffectElement;
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
return { return {
defaultTab: "effects", defaultTab: "effects",
tabs: [buildStandaloneEffectTab({ element })], tabs: [buildStandaloneEffectTab({ element })],
}; };
} }
export function getPropertiesConfig({ export function getPropertiesConfig({
element, element,
mediaAssets, mediaAssets,
}: { }: {
element: TimelineElement; element: TimelineElement;
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[];
}): ElementPropertiesConfig { }): ElementPropertiesConfig {
switch (element.type) { switch (element.type) {
case "text": case "text":
return getTextConfig({ element }); return getTextConfig({ element });
case "video": { case "video": {
const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId); const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
return getVideoConfig({ element, mediaAsset }); return getVideoConfig({ element, mediaAsset });
} }
case "image": case "image":
return getImageConfig({ element }); return getImageConfig({ element });
case "sticker": case "sticker":
return getStickerConfig({ element }); return getStickerConfig({ element });
case "graphic": case "graphic":
return getGraphicConfig({ element }); return getGraphicConfig({ element });
case "audio": case "audio":
return getAudioConfig({ element }); return getAudioConfig({ element });
case "effect": case "effect":
return getEffectConfig({ element }); return getEffectConfig({ element });
} }
} }

View File

@ -1,230 +1,230 @@
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { clamp } from "@/utils/math"; import { clamp } from "@/utils/math";
import { NumberField } from "@/components/ui/number-field"; import { NumberField } from "@/components/ui/number-field";
import { OcCheckerboardIcon } from "@opencut/ui/icons"; import { OcCheckerboardIcon } from "@/components/icons";
import { Fragment, useRef } from "react"; import { Fragment, useRef } from "react";
import { useMenuPreview } from "@/hooks/use-menu-preview"; import { useMenuPreview } from "@/hooks/use-menu-preview";
import { import {
Section, Section,
SectionContent, SectionContent,
SectionField, SectionField,
SectionHeader, SectionHeader,
SectionTitle, SectionTitle,
} from "@/components/section"; } from "@/components/section";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import type { BlendMode } from "@/lib/rendering"; import type { BlendMode } from "@/lib/rendering";
import type { ElementType } from "@/lib/timeline"; import type { ElementType } from "@/lib/timeline";
import type { ElementAnimations } from "@/lib/animation/types"; import type { ElementAnimations } from "@/lib/animation/types";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { RainDropIcon } from "@hugeicons/core-free-icons"; import { RainDropIcon } from "@hugeicons/core-free-icons";
import { KeyframeToggle } from "../components/keyframe-toggle"; import { KeyframeToggle } from "../components/keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { useElementPlayhead } from "../hooks/use-element-playhead"; import { useElementPlayhead } from "../hooks/use-element-playhead";
import { resolveOpacityAtTime } from "@/lib/animation"; import { resolveOpacityAtTime } from "@/lib/animation";
import { DEFAULTS } from "@/lib/timeline/defaults"; import { DEFAULTS } from "@/lib/timeline/defaults";
import { isPropertyAtDefault } from "./transform-tab"; import { isPropertyAtDefault } from "./transform-tab";
type BlendingElement = { type BlendingElement = {
id: string; id: string;
opacity: number; opacity: number;
type: ElementType; type: ElementType;
blendMode?: BlendMode; blendMode?: BlendMode;
startTime: number; startTime: number;
duration: number; duration: number;
animations?: ElementAnimations; animations?: ElementAnimations;
}; };
const BLEND_MODE_GROUPS = [ const BLEND_MODE_GROUPS = [
[{ value: "normal", label: "Normal" }], [{ value: "normal", label: "Normal" }],
[ [
{ value: "darken", label: "Darken" }, { value: "darken", label: "Darken" },
{ value: "multiply", label: "Multiply" }, { value: "multiply", label: "Multiply" },
{ value: "color-burn", label: "Color Burn" }, { value: "color-burn", label: "Color Burn" },
], ],
[ [
{ value: "lighten", label: "Lighten" }, { value: "lighten", label: "Lighten" },
{ value: "screen", label: "Screen" }, { value: "screen", label: "Screen" },
{ value: "plus-lighter", label: "Plus Lighter" }, { value: "plus-lighter", label: "Plus Lighter" },
{ value: "color-dodge", label: "Color Dodge" }, { value: "color-dodge", label: "Color Dodge" },
], ],
[ [
{ value: "overlay", label: "Overlay" }, { value: "overlay", label: "Overlay" },
{ value: "soft-light", label: "Soft Light" }, { value: "soft-light", label: "Soft Light" },
{ value: "hard-light", label: "Hard Light" }, { value: "hard-light", label: "Hard Light" },
], ],
[ [
{ value: "difference", label: "Difference" }, { value: "difference", label: "Difference" },
{ value: "exclusion", label: "Exclusion" }, { value: "exclusion", label: "Exclusion" },
], ],
[ [
{ value: "hue", label: "Hue" }, { value: "hue", label: "Hue" },
{ value: "saturation", label: "Saturation" }, { value: "saturation", label: "Saturation" },
{ value: "color", label: "Color" }, { value: "color", label: "Color" },
{ value: "luminosity", label: "Luminosity" }, { value: "luminosity", label: "Luminosity" },
], ],
]; ];
export function BlendingTab({ export function BlendingTab({
element, element,
trackId, trackId,
}: { }: {
element: BlendingElement; element: BlendingElement;
trackId: string; trackId: string;
}) { }) {
const editor = useEditor(); const editor = useEditor();
const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
const committedBlendModeRef = useRef(blendMode); const committedBlendModeRef = useRef(blendMode);
if (!isPreviewActive) { if (!isPreviewActive) {
committedBlendModeRef.current = blendMode; committedBlendModeRef.current = blendMode;
} }
const { const {
onPointerLeave, onPointerLeave,
onOpenChange: handleBlendModeOpenChange, onOpenChange: handleBlendModeOpenChange,
markCommitted, markCommitted,
} = useMenuPreview(); } = useMenuPreview();
const previewBlendMode = ({ value }: { value: BlendMode }) => const previewBlendMode = ({ value }: { value: BlendMode }) =>
editor.timeline.previewElements({ editor.timeline.previewElements({
updates: [ updates: [
{ trackId, elementId: element.id, updates: { blendMode: value } }, { trackId, elementId: element.id, updates: { blendMode: value } },
], ],
}); });
const commitBlendMode = (value: string) => { const commitBlendMode = (value: string) => {
if (editor.timeline.isPreviewActive()) { if (editor.timeline.isPreviewActive()) {
editor.timeline.commitPreview(); editor.timeline.commitPreview();
} else { } else {
editor.timeline.updateElements({ editor.timeline.updateElements({
updates: [ updates: [
{ {
trackId, trackId,
elementId: element.id, elementId: element.id,
updates: { blendMode: value as BlendMode }, updates: { blendMode: value as BlendMode },
}, },
], ],
}); });
} }
markCommitted(); markCommitted();
}; };
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
startTime: element.startTime, startTime: element.startTime,
duration: element.duration, duration: element.duration,
}); });
const resolvedOpacity = resolveOpacityAtTime({ const resolvedOpacity = resolveOpacityAtTime({
baseOpacity: element.opacity, baseOpacity: element.opacity,
animations: element.animations, animations: element.animations,
localTime, localTime,
}); });
const opacity = useKeyframedNumberProperty({ const opacity = useKeyframedNumberProperty({
trackId, trackId,
elementId: element.id, elementId: element.id,
animations: element.animations, animations: element.animations,
propertyPath: "opacity", propertyPath: "opacity",
localTime, localTime,
isPlayheadWithinElementRange, isPlayheadWithinElementRange,
displayValue: Math.round(resolvedOpacity * 100).toString(), displayValue: Math.round(resolvedOpacity * 100).toString(),
parse: (input) => { parse: (input) => {
const parsed = parseFloat(input); const parsed = parseFloat(input);
if (Number.isNaN(parsed)) return null; if (Number.isNaN(parsed)) return null;
return clamp({ value: parsed, min: 0, max: 100 }) / 100; return clamp({ value: parsed, min: 0, max: 100 }) / 100;
}, },
valueAtPlayhead: resolvedOpacity, valueAtPlayhead: resolvedOpacity,
step: 0.01, step: 0.01,
buildBaseUpdates: ({ value }) => ({ opacity: value }), buildBaseUpdates: ({ value }) => ({ opacity: value }),
}); });
return ( return (
<Section collapsible sectionKey={`${element.id}:blending`}> <Section collapsible sectionKey={`${element.id}:blending`}>
<SectionHeader> <SectionHeader>
<SectionTitle>Blending</SectionTitle> <SectionTitle>Blending</SectionTitle>
</SectionHeader> </SectionHeader>
<SectionContent> <SectionContent>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<SectionField <SectionField
label="Opacity" label="Opacity"
className="w-1/2" className="w-1/2"
beforeLabel={ beforeLabel={
<KeyframeToggle <KeyframeToggle
isActive={opacity.isKeyframedAtTime} isActive={opacity.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange} isDisabled={!isPlayheadWithinElementRange}
title="Toggle opacity keyframe" title="Toggle opacity keyframe"
onToggle={opacity.toggleKeyframe} onToggle={opacity.toggleKeyframe}
/> />
} }
> >
<NumberField <NumberField
className="w-full" className="w-full"
icon={ icon={
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
} }
value={opacity.displayValue} value={opacity.displayValue}
min={0} min={0}
max={100} max={100}
onFocus={opacity.onFocus} onFocus={opacity.onFocus}
onChange={opacity.onChange} onChange={opacity.onChange}
onBlur={opacity.onBlur} onBlur={opacity.onBlur}
onScrub={opacity.scrubTo} onScrub={opacity.scrubTo}
onScrubEnd={opacity.commitScrub} onScrubEnd={opacity.commitScrub}
onReset={() => onReset={() =>
opacity.commitValue({ value: DEFAULTS.element.opacity }) opacity.commitValue({ value: DEFAULTS.element.opacity })
} }
isDefault={isPropertyAtDefault({ isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
isPlayheadWithinElementRange, isPlayheadWithinElementRange,
resolvedValue: resolvedOpacity, resolvedValue: resolvedOpacity,
staticValue: element.opacity, staticValue: element.opacity,
defaultValue: DEFAULTS.element.opacity, defaultValue: DEFAULTS.element.opacity,
})} })}
dragSensitivity="slow" dragSensitivity="slow"
/> />
</SectionField> </SectionField>
<SectionField label="Blend mode" className="w-1/2"> <SectionField label="Blend mode" className="w-1/2">
<Select <Select
value={committedBlendModeRef.current} value={committedBlendModeRef.current}
onOpenChange={handleBlendModeOpenChange} onOpenChange={handleBlendModeOpenChange}
onValueChange={commitBlendMode} onValueChange={commitBlendMode}
> >
<SelectTrigger <SelectTrigger
icon={<HugeiconsIcon icon={RainDropIcon} />} icon={<HugeiconsIcon icon={RainDropIcon} />}
className="w-full" className="w-full"
> >
<SelectValue placeholder="Select blend mode" /> <SelectValue placeholder="Select blend mode" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="w-36" onPointerLeave={onPointerLeave}> <SelectContent className="w-36" onPointerLeave={onPointerLeave}>
{BLEND_MODE_GROUPS.map((group, groupIndex) => ( {BLEND_MODE_GROUPS.map((group, groupIndex) => (
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}> <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
{group.map((option) => ( {group.map((option) => (
<SelectItem <SelectItem
key={option.value} key={option.value}
value={option.value} value={option.value}
onPointerEnter={() => onPointerEnter={() =>
previewBlendMode({ value: option.value as BlendMode }) previewBlendMode({ value: option.value as BlendMode })
} }
> >
{option.label} {option.label}
</SelectItem> </SelectItem>
))} ))}
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
<SelectSeparator /> <SelectSeparator />
) : null} ) : null}
</Fragment> </Fragment>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</SectionField> </SectionField>
</div> </div>
</SectionContent> </SectionContent>
</Section> </Section>
); );
} }

View File

@ -53,7 +53,7 @@ import {
SectionTitle, SectionTitle,
} from "@/components/section"; } from "@/components/section";
import { usePropertyDraft } from "../hooks/use-property-draft"; import { usePropertyDraft } from "../hooks/use-property-draft";
import { OcMirrorIcon, OcShapesIcon } from "@opencut/ui/icons"; import { OcMirrorIcon, OcShapesIcon } from "@/components/icons";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
type MasksTabProps = { type MasksTabProps = {

View File

@ -13,7 +13,7 @@ import {
VolumeOffIcon, VolumeOffIcon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import { OcShapesIcon, OcVideoIcon } from "@opencut/ui/icons"; import { OcShapesIcon, OcVideoIcon } from "@/components/icons";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,

View File

@ -1,69 +1,69 @@
import { OcDataBuddyIcon, OcMarbleIcon } from "@opencut/ui/icons"; import { OcDataBuddyIcon, OcMarbleIcon } from "@/components/icons";
export const SITE_URL = "https://opencut.app"; export const SITE_URL = "https://opencut.app";
export const SITE_INFO = { export const SITE_INFO = {
title: "OpenCut", title: "OpenCut",
description: description:
"A simple but powerful video editor that gets the job done. In your browser.", "A simple but powerful video editor that gets the job done. In your browser.",
url: SITE_URL, url: SITE_URL,
openGraphImage: "/open-graph/default.jpg", openGraphImage: "/open-graph/default.jpg",
twitterImage: "/open-graph/default.jpg", twitterImage: "/open-graph/default.jpg",
favicon: "/favicon.ico", favicon: "/favicon.ico",
}; };
export type ExternalTool = { export type ExternalTool = {
name: string; name: string;
description: string; description: string;
url: string; url: string;
icon: React.ElementType; icon: React.ElementType;
}; };
export const EXTERNAL_TOOLS: ExternalTool[] = [ export const EXTERNAL_TOOLS: ExternalTool[] = [
{ {
name: "Marble", name: "Marble",
description: description:
"Modern headless CMS for content management and the blog for OpenCut", "Modern headless CMS for content management and the blog for OpenCut",
url: "https://marblecms.com?utm_source=opencut", url: "https://marblecms.com?utm_source=opencut",
icon: OcMarbleIcon, icon: OcMarbleIcon,
}, },
{ {
name: "Databuddy", name: "Databuddy",
description: "GDPR compliant analytics and user insights for OpenCut", description: "GDPR compliant analytics and user insights for OpenCut",
url: "https://databuddy.cc?utm_source=opencut", url: "https://databuddy.cc?utm_source=opencut",
icon: OcDataBuddyIcon, icon: OcDataBuddyIcon,
}, },
]; ];
export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg"; export const DEFAULT_LOGO_URL = "/logos/opencut/svg/logo.svg";
export const SOCIAL_LINKS = { export const SOCIAL_LINKS = {
x: "https://x.com/opencutapp", x: "https://x.com/opencutapp",
github: "https://github.com/OpenCut-app/OpenCut", github: "https://github.com/OpenCut-app/OpenCut",
discord: "https://discord.com/invite/Mu3acKZvCp", discord: "https://discord.com/invite/Mu3acKZvCp",
}; };
export type Sponsor = { export type Sponsor = {
name: string; name: string;
url: string; url: string;
logo: string; logo: string;
description: string; description: string;
invertOnDark?: boolean; invertOnDark?: boolean;
}; };
export const SPONSORS: Sponsor[] = [ export const SPONSORS: Sponsor[] = [
{ {
name: "Fal.ai", name: "Fal.ai",
url: "https://fal.ai?utm_source=opencut", url: "https://fal.ai?utm_source=opencut",
logo: "/logos/others/fal.svg", logo: "/logos/others/fal.svg",
description: "Generative image, video, and audio models all in one place.", description: "Generative image, video, and audio models all in one place.",
invertOnDark: true, invertOnDark: true,
}, },
{ {
name: "Vercel", name: "Vercel",
url: "https://vercel.com?utm_source=opencut", url: "https://vercel.com?utm_source=opencut",
logo: "/logos/others/vercel.svg", logo: "/logos/others/vercel.svg",
description: "Platform where we deploy and host OpenCut.", description: "Platform where we deploy and host OpenCut.",
invertOnDark: true, invertOnDark: true,
}, },
]; ];

View File

@ -1,6 +1,6 @@
import { createAuthClient } from "better-auth/react"; import { createAuthClient } from "better-auth/react";
import { webEnv } from "@opencut/env/web"; import { webEnv } from "@/lib/env/web";
export const { signIn, signUp, useSession } = createAuthClient({ export const { signIn, signUp, useSession } = createAuthClient({
baseURL: webEnv.NEXT_PUBLIC_SITE_URL, baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
}); });

View File

@ -1,43 +1,43 @@
import { betterAuth, type RateLimit } from "better-auth"; import { betterAuth, type RateLimit } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { Redis } from "@upstash/redis"; import { Redis } from "@upstash/redis";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { webEnv } from "@opencut/env/web"; import { webEnv } from "@/lib/env/web";
const redis = new Redis({ const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL, url: webEnv.UPSTASH_REDIS_REST_URL,
token: webEnv.UPSTASH_REDIS_REST_TOKEN, token: webEnv.UPSTASH_REDIS_REST_TOKEN,
}); });
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "pg", provider: "pg",
usePlural: true, usePlural: true,
}), }),
secret: webEnv.BETTER_AUTH_SECRET, secret: webEnv.BETTER_AUTH_SECRET,
user: { user: {
deleteUser: { deleteUser: {
enabled: true, enabled: true,
}, },
}, },
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
rateLimit: { rateLimit: {
storage: "secondary-storage", storage: "secondary-storage",
customStorage: { customStorage: {
get: async (key) => { get: async (key) => {
const value = await redis.get(key); const value = await redis.get(key);
return value as RateLimit | undefined; return value as RateLimit | undefined;
}, },
set: async (key, value) => { set: async (key, value) => {
await redis.set(key, value); await redis.set(key, value);
}, },
}, },
}, },
baseURL: webEnv.NEXT_PUBLIC_SITE_URL, baseURL: webEnv.NEXT_PUBLIC_SITE_URL,
appName: "OpenCut", appName: "OpenCut",
trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL], trustedOrigins: [webEnv.NEXT_PUBLIC_SITE_URL],
}); });
export type Auth = typeof auth; export type Auth = typeof auth;

View File

@ -1,7 +1,7 @@
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import * as schema from "./schema"; import * as schema from "./schema";
import { webEnv } from "@opencut/env/web"; import { webEnv } from "@/lib/env/web";
let _db: ReturnType<typeof drizzle> | null = null; let _db: ReturnType<typeof drizzle> | null = null;

View File

@ -1,21 +1,21 @@
import { Ratelimit } from "@upstash/ratelimit"; import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis"; import { Redis } from "@upstash/redis";
import { webEnv } from "@opencut/env/web"; import { webEnv } from "@/lib/env/web";
const redis = new Redis({ const redis = new Redis({
url: webEnv.UPSTASH_REDIS_REST_URL, url: webEnv.UPSTASH_REDIS_REST_URL,
token: webEnv.UPSTASH_REDIS_REST_TOKEN, token: webEnv.UPSTASH_REDIS_REST_TOKEN,
}); });
export const baseRateLimit = new Ratelimit({ export const baseRateLimit = new Ratelimit({
redis, redis,
limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute limiter: Ratelimit.slidingWindow(100, "1 m"), // 100 requests per minute
analytics: true, analytics: true,
prefix: "rate-limit", prefix: "rate-limit",
}); });
export async function checkRateLimit({ request }: { request: Request }) { export async function checkRateLimit({ request }: { request: Request }) {
const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success } = await baseRateLimit.limit(ip); const { success } = await baseRateLimit.limit(ip);
return { success, limited: !success }; return { success, limited: !success };
} }

View File

@ -24,8 +24,6 @@
"@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.6", "@hugeicons/react": "^1.1.6",
"@huggingface/transformers": "^3.8.1", "@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
"@opennextjs/cloudflare": "^1.18.0", "@opennextjs/cloudflare": "^1.18.0",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@ -79,7 +77,7 @@
"unified": "^11.0.5", "unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1", "use-deep-compare-effect": "^1.8.1",
"wavesurfer.js": "^7.9.8", "wavesurfer.js": "^7.9.8",
"zod": "^3.25.67", "zod": "^3.25.76",
"zustand": "^5.0.2", "zustand": "^5.0.2",
}, },
"devDependencies": { "devDependencies": {
@ -104,32 +102,6 @@
"wrangler": "^4.77.0", "wrangler": "^4.77.0",
}, },
}, },
"packages/env": {
"name": "@opencut/env",
"version": "0.0.0",
"dependencies": {
"zod": "^4.0.5",
},
"devDependencies": {
"@types/bun": "latest",
"@types/node": "^24.2.1",
"dotenv": "^16.4.7",
"typescript": "^5.8.3",
},
},
"packages/ui": {
"name": "@opencut/ui",
"version": "0.0.0",
"dependencies": {
"@iconify/react": "^6.0.2",
"@types/react": "^19.2.7",
"react": "^19.2.0",
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.8.3",
},
},
}, },
"trustedDependencies": [ "trustedDependencies": [
"@tailwindcss/oxide", "@tailwindcss/oxide",
@ -385,10 +357,6 @@
"@huggingface/transformers": ["@huggingface/transformers@3.8.1", "", { "dependencies": { "@huggingface/jinja": "^0.5.3", "onnxruntime-node": "1.21.0", "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", "sharp": "^0.34.1" } }, "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA=="], "@huggingface/transformers": ["@huggingface/transformers@3.8.1", "", { "dependencies": { "@huggingface/jinja": "^0.5.3", "onnxruntime-node": "1.21.0", "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", "sharp": "^0.34.1" } }, "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA=="],
"@iconify/react": ["@iconify/react@6.0.2", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
@ -509,10 +477,6 @@
"@node-minify/utils": ["@node-minify/utils@8.0.6", "", { "dependencies": { "gzip-size": "6.0.0" } }, "sha512-csY4qcR7jUwiZmkreNTJhcypQfts2aY2CK+a+rXgXUImZiZiySh0FvwHjRnlqWKvg+y6ae9lHFzDRjBTmqlTIQ=="], "@node-minify/utils": ["@node-minify/utils@8.0.6", "", { "dependencies": { "gzip-size": "6.0.0" } }, "sha512-csY4qcR7jUwiZmkreNTJhcypQfts2aY2CK+a+rXgXUImZiZiySh0FvwHjRnlqWKvg+y6ae9lHFzDRjBTmqlTIQ=="],
"@opencut/env": ["@opencut/env@workspace:packages/env"],
"@opencut/ui": ["@opencut/ui@workspace:packages/ui"],
"@opencut/web": ["@opencut/web@workspace:apps/web"], "@opencut/web": ["@opencut/web@workspace:apps/web"],
"@opennextjs/aws": ["@opennextjs/aws@3.9.16", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-jQQStCysIllNCPqz5W2KSguXpr+ETlOcD8SyNu+h9zwpRVYk4uEPQge+ErG3avI5xsT8vKA7EGLYG59dhj/B6Q=="], "@opennextjs/aws": ["@opennextjs/aws@3.9.16", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5" }, "bin": { "open-next": "dist/index.js" } }, "sha512-jQQStCysIllNCPqz5W2KSguXpr+ETlOcD8SyNu+h9zwpRVYk4uEPQge+ErG3avI5xsT8vKA7EGLYG59dhj/B6Q=="],
@ -1835,7 +1799,7 @@
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zustand": ["zustand@5.0.6", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A=="], "zustand": ["zustand@5.0.6", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A=="],
@ -1851,6 +1815,8 @@
"@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="], "@aws-sdk/nested-clients/@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.5", "", { "dependencies": { "@aws-sdk/types": "^3.973.6", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" } }, "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw=="],
"@better-auth/core/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
@ -1871,8 +1837,6 @@
"@node-minify/terser/terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="], "@node-minify/terser/terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
"@opencut/web/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
@ -1989,6 +1953,10 @@
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"better-auth/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"better-call/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"browserslist/caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], "browserslist/caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],

View File

@ -1,21 +0,0 @@
{
"name": "@opencut/env",
"version": "0.0.0",
"description": "Environment package for OpenCut",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./web": "./src/web.ts",
"./tools": "./src/tools.ts"
},
"dependencies": {
"zod": "^4.0.5"
},
"devDependencies": {
"dotenv": "^16.4.7",
"@types/bun": "latest",
"@types/node": "^24.2.1",
"typescript": "^5.8.3"
}
}

View File

@ -1,19 +0,0 @@
{
"name": "@opencut/ui",
"version": "0.0.0",
"description": "UI package for OpenCut",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
"./icons": "./src/icons/index.tsx"
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@types/react": "^19.2.7",
"react": "^19.2.0"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.8.3"
}
}

View File

@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}